All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 44s
CI/CD / test-python-backend-compliance (push) Successful in 33s
CI/CD / test-python-document-crawler (push) Successful in 25s
CI/CD / test-python-dsms-gateway (push) Successful in 19s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Successful in 6s
Add "Dokumentenursprung" filter dropdown to the control library page. Extracts unique source_citation.source values from controls, sorted by frequency. Includes "Ohne Quelle" option for controls without source info. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
643 lines
25 KiB
TypeScript
643 lines
25 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
|
import {
|
|
Shield, Search, ChevronRight, ChevronLeft, Filter, Lock,
|
|
BookOpen, Plus, Zap, BarChart3, ListChecks,
|
|
ChevronsLeft, ChevronsRight,
|
|
} from 'lucide-react'
|
|
import {
|
|
CanonicalControl, Framework, BACKEND_URL, EMPTY_CONTROL,
|
|
SeverityBadge, StateBadge, LicenseRuleBadge, VerificationMethodBadge, CategoryBadge, TargetAudienceBadge,
|
|
getDomain, VERIFICATION_METHODS, CATEGORY_OPTIONS, TARGET_AUDIENCE_OPTIONS,
|
|
} from './components/helpers'
|
|
import { ControlForm } from './components/ControlForm'
|
|
import { ControlDetail } from './components/ControlDetail'
|
|
import { GeneratorModal } from './components/GeneratorModal'
|
|
|
|
// =============================================================================
|
|
// CONTROL LIBRARY PAGE
|
|
// =============================================================================
|
|
|
|
export default function ControlLibraryPage() {
|
|
const [frameworks, setFrameworks] = useState<Framework[]>([])
|
|
const [controls, setControls] = useState<CanonicalControl[]>([])
|
|
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 [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 [audienceFilter, setAudienceFilter] = useState<string>('')
|
|
const [sourceFilter, setSourceFilter] = useState<string>('')
|
|
|
|
// CRUD state
|
|
const [mode, setMode] = useState<'list' | 'detail' | 'create' | 'edit'>('list')
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
// Generator state
|
|
const [showGenerator, setShowGenerator] = useState(false)
|
|
const [processedStats, setProcessedStats] = useState<Array<Record<string, unknown>>>([])
|
|
const [showStats, setShowStats] = useState(false)
|
|
|
|
// Pagination
|
|
const [currentPage, setCurrentPage] = useState(1)
|
|
const PAGE_SIZE = 50
|
|
|
|
// Review mode
|
|
const [reviewMode, setReviewMode] = useState(false)
|
|
const [reviewIndex, setReviewIndex] = useState(0)
|
|
|
|
// Load data
|
|
const loadData = useCallback(async () => {
|
|
try {
|
|
setLoading(true)
|
|
const [fwRes, ctrlRes] = await Promise.all([
|
|
fetch(`${BACKEND_URL}?endpoint=frameworks`),
|
|
fetch(`${BACKEND_URL}?endpoint=controls`),
|
|
])
|
|
|
|
if (fwRes.ok) setFrameworks(await fwRes.json())
|
|
if (ctrlRes.ok) setControls(await ctrlRes.json())
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { loadData() }, [loadData])
|
|
|
|
// Derived: unique domains
|
|
const domains = useMemo(() => {
|
|
const set = new Set(controls.map(c => getDomain(c.control_id)))
|
|
return Array.from(set).sort()
|
|
}, [controls])
|
|
|
|
// Derived: unique document sources (sorted by frequency)
|
|
const documentSources = useMemo(() => {
|
|
const counts = new Map<string, number>()
|
|
let noSource = 0
|
|
for (const c of controls) {
|
|
const src = c.source_citation?.source
|
|
if (src) {
|
|
counts.set(src, (counts.get(src) || 0) + 1)
|
|
} else {
|
|
noSource++
|
|
}
|
|
}
|
|
const sorted = Array.from(counts.entries()).sort((a, b) => b[1] - a[1])
|
|
return { sources: sorted, noSourceCount: noSource }
|
|
}, [controls])
|
|
|
|
// Filtered controls
|
|
const filteredControls = useMemo(() => {
|
|
return controls.filter(c => {
|
|
if (severityFilter && c.severity !== severityFilter) return false
|
|
if (domainFilter && getDomain(c.control_id) !== domainFilter) return false
|
|
if (stateFilter && c.release_state !== stateFilter) return false
|
|
if (verificationFilter && c.verification_method !== verificationFilter) return false
|
|
if (categoryFilter && c.category !== categoryFilter) return false
|
|
if (audienceFilter && c.target_audience !== audienceFilter) return false
|
|
if (sourceFilter) {
|
|
const src = c.source_citation?.source || ''
|
|
if (sourceFilter === '__none__') {
|
|
if (src) return false
|
|
} else {
|
|
if (src !== sourceFilter) return false
|
|
}
|
|
}
|
|
if (searchQuery) {
|
|
const q = searchQuery.toLowerCase()
|
|
return (
|
|
c.control_id.toLowerCase().includes(q) ||
|
|
c.title.toLowerCase().includes(q) ||
|
|
c.objective.toLowerCase().includes(q) ||
|
|
c.tags.some(t => t.toLowerCase().includes(q))
|
|
)
|
|
}
|
|
return true
|
|
})
|
|
}, [controls, severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, searchQuery])
|
|
|
|
// Reset page when filters change
|
|
useEffect(() => { setCurrentPage(1) }, [severityFilter, domainFilter, stateFilter, verificationFilter, categoryFilter, audienceFilter, sourceFilter, searchQuery])
|
|
|
|
// Pagination
|
|
const totalPages = Math.max(1, Math.ceil(filteredControls.length / PAGE_SIZE))
|
|
const paginatedControls = useMemo(() => {
|
|
const start = (currentPage - 1) * PAGE_SIZE
|
|
return filteredControls.slice(start, start + PAGE_SIZE)
|
|
}, [filteredControls, currentPage])
|
|
|
|
// Review queue items
|
|
const reviewItems = useMemo(() => {
|
|
return controls.filter(c => ['needs_review', 'too_close', 'duplicate'].includes(c.release_state))
|
|
}, [controls])
|
|
|
|
// CRUD handlers
|
|
const handleCreate = async (data: typeof EMPTY_CONTROL) => {
|
|
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 loadData()
|
|
setMode('list')
|
|
} catch {
|
|
alert('Netzwerkfehler')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleUpdate = async (data: typeof EMPTY_CONTROL) => {
|
|
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 loadData()
|
|
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 loadData()
|
|
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 loadData()
|
|
if (reviewMode) {
|
|
const remaining = controls.filter(c =>
|
|
['needs_review', 'too_close', 'duplicate'].includes(c.release_state) && c.control_id !== controlId
|
|
)
|
|
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 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 = () => {
|
|
if (reviewItems.length === 0) return
|
|
setReviewMode(true)
|
|
setReviewIndex(0)
|
|
setSelectedControl(reviewItems[0])
|
|
setMode('detail')
|
|
}
|
|
|
|
// Loading
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-purple-600 border-t-transparent" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex items-center justify-center h-96">
|
|
<p className="text-red-600">{error}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// CREATE/EDIT MODE
|
|
if (mode === 'create') {
|
|
return <ControlForm initial={EMPTY_CONTROL} onSave={handleCreate} onCancel={() => setMode('list')} saving={saving} />
|
|
}
|
|
|
|
if (mode === 'edit' && selectedControl) {
|
|
return (
|
|
<ControlForm
|
|
initial={{
|
|
...EMPTY_CONTROL,
|
|
...selectedControl,
|
|
risk_score: selectedControl.risk_score,
|
|
implementation_effort: selectedControl.implementation_effort,
|
|
open_anchors: selectedControl.open_anchors.length > 0
|
|
? selectedControl.open_anchors
|
|
: [{ framework: '', ref: '', url: '' }],
|
|
requirements: selectedControl.requirements.length > 0 ? selectedControl.requirements : [''],
|
|
test_procedure: selectedControl.test_procedure.length > 0 ? selectedControl.test_procedure : [''],
|
|
evidence: selectedControl.evidence.length > 0 ? selectedControl.evidence : [{ type: '', description: '' }],
|
|
}}
|
|
onSave={handleUpdate}
|
|
onCancel={() => { setMode('detail') }}
|
|
saving={saving}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// DETAIL MODE
|
|
if (mode === 'detail' && selectedControl) {
|
|
return (
|
|
<ControlDetail
|
|
ctrl={selectedControl}
|
|
onBack={() => { setMode('list'); setSelectedControl(null); setReviewMode(false) }}
|
|
onEdit={() => setMode('edit')}
|
|
onDelete={handleDelete}
|
|
onReview={handleReview}
|
|
onRefresh={loadData}
|
|
reviewMode={reviewMode}
|
|
reviewIndex={reviewIndex}
|
|
reviewTotal={reviewItems.length}
|
|
onReviewPrev={() => {
|
|
const idx = Math.max(0, reviewIndex - 1)
|
|
setReviewIndex(idx)
|
|
setSelectedControl(reviewItems[idx])
|
|
}}
|
|
onReviewNext={() => {
|
|
const idx = Math.min(reviewItems.length - 1, reviewIndex + 1)
|
|
setReviewIndex(idx)
|
|
setSelectedControl(reviewItems[idx])
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// =========================================================================
|
|
// LIST VIEW
|
|
// =========================================================================
|
|
|
|
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">
|
|
{controls.length} unabhaengig formulierte Security Controls —{' '}
|
|
{controls.reduce((sum, c) => sum + c.open_anchors.length, 0)} Open-Source-Referenzen
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{reviewItems.length > 0 && (
|
|
<button
|
|
onClick={enterReviewMode}
|
|
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 ({reviewItems.length})
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => { setShowStats(!showStats); if (!showStats) loadProcessedStats() }}
|
|
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={() => setMode('create')}
|
|
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 */}
|
|
{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..."
|
|
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>
|
|
</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</option>
|
|
<option value="high">Hoch</option>
|
|
<option value="medium">Mittel</option>
|
|
<option value="low">Niedrig</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>
|
|
{domains.map(d => (
|
|
<option key={d} value={d}>{d}</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</option>
|
|
<option value="approved">Approved</option>
|
|
<option value="needs_review">Review noetig</option>
|
|
<option value="too_close">Zu aehnlich</option>
|
|
<option value="duplicate">Duplikat</option>
|
|
</select>
|
|
<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}</option>
|
|
))}
|
|
</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}</option>
|
|
))}
|
|
</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>
|
|
{Object.entries(TARGET_AUDIENCE_OPTIONS).map(([k, v]) => (
|
|
<option key={k} value={k}>{v.label}</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>
|
|
<option value="__none__">Ohne Quelle ({documentSources.noSourceCount})</option>
|
|
{documentSources.sources.map(([src, count]) => (
|
|
<option key={src} value={src}>{src} ({count})</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Processing Stats */}
|
|
{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>
|
|
|
|
{/* Generator Modal */}
|
|
{showGenerator && (
|
|
<GeneratorModal
|
|
onClose={() => setShowGenerator(false)}
|
|
onComplete={() => loadData()}
|
|
/>
|
|
)}
|
|
|
|
{/* 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">
|
|
<span>
|
|
{filteredControls.length} Controls gefunden
|
|
{filteredControls.length !== controls.length && ` (von ${controls.length} gesamt)`}
|
|
</span>
|
|
<span>Seite {currentPage} von {totalPages}</span>
|
|
</div>
|
|
|
|
{/* Control List */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
<div className="space-y-3">
|
|
{paginatedControls.map(ctrl => (
|
|
<button
|
|
key={ctrl.control_id}
|
|
onClick={() => { setSelectedControl(ctrl); setMode('detail') }}
|
|
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} />
|
|
<TargetAudienceBadge audience={ctrl.target_audience} />
|
|
{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>
|
|
|
|
{/* Open anchors summary */}
|
|
<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}</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>
|
|
))}
|
|
|
|
{filteredControls.length === 0 && (
|
|
<div className="text-center py-12 text-gray-400 text-sm">
|
|
{controls.length === 0
|
|
? '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>
|
|
|
|
{/* Page numbers */}
|
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
|
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
|
.reduce<(number | 'dots')[]>((acc, p, i, arr) => {
|
|
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('dots')
|
|
acc.push(p)
|
|
return acc
|
|
}, [])
|
|
.map((p, i) =>
|
|
p === 'dots' ? (
|
|
<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>
|
|
)
|
|
}
|