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>
395 lines
23 KiB
TypeScript
395 lines
23 KiB
TypeScript
'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>
|
||
)
|
||
}
|