Files
breakpilot-compliance/admin-compliance/components/sdk/einwilligungen/DataPointCatalog.tsx
Sharang Parnerkar ada50f0466 refactor(admin): split AIUseCaseModuleEditor, DataPointCatalog, ProjectSelector components
AIUseCaseModuleEditor (698 LOC) → thin orchestrator (187) + constants (29) +
barrel tabs (4) + tabs implementation split into SystemData (261), PurposeAct
(149), RisksReview (219). DataPointCatalog (658 LOC) → main (291) + helpers
(190) + CategoryGroup (124) + Row (108). ProjectSelector (656 LOC) → main
(211) + CreateProjectDialog (169) + ProjectActionDialog (140) + ProjectCard
(128). All files now under 300 LOC soft target and 500 LOC hard cap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 09:16:21 +02:00

292 lines
10 KiB
TypeScript

'use client'
/**
* DataPointCatalog Component
*
* Zeigt den vollstaendigen Datenpunktkatalog an.
* Ermoeglicht Filterung, Suche und Auswahl von Datenpunkten.
* Unterstützt 18 Kategorien (A-R) inkl. Art. 9 DSGVO Warnungen.
*/
import { useState, useMemo } from 'react'
import { Search } from 'lucide-react'
import {
DataPoint,
DataPointCategory,
RiskLevel,
LegalBasis,
SupportedLanguage,
CATEGORY_METADATA,
RISK_LEVEL_STYLING,
LEGAL_BASIS_INFO,
} from '@/lib/sdk/einwilligungen/types'
import { searchDataPoints } from '@/lib/sdk/einwilligungen/catalog/loader'
import { SpecialCategoryWarning } from './DataPointCatalogHelpers'
import { DataPointCategoryGroup } from './DataPointCategoryGroup'
// =============================================================================
// TYPES
// =============================================================================
interface DataPointCatalogProps {
dataPoints: DataPoint[]
selectedIds: string[]
onToggle: (id: string) => void
onSelectAll?: () => void
onDeselectAll?: () => void
language?: SupportedLanguage
showFilters?: boolean
readOnly?: boolean
}
// =============================================================================
// CATEGORY ORDER
// =============================================================================
const ALL_CATEGORIES: DataPointCategory[] = [
'MASTER_DATA', // A
'CONTACT_DATA', // B
'AUTHENTICATION', // C
'CONSENT', // D
'COMMUNICATION', // E
'PAYMENT', // F
'USAGE_DATA', // G
'LOCATION', // H
'DEVICE_DATA', // I
'MARKETING', // J
'ANALYTICS', // K
'SOCIAL_MEDIA', // L
'HEALTH_DATA', // M - Art. 9 DSGVO
'EMPLOYEE_DATA', // N - BDSG § 26
'CONTRACT_DATA', // O
'LOG_DATA', // P
'AI_DATA', // Q - AI Act
'SECURITY', // R
]
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function DataPointCatalog({
dataPoints,
selectedIds,
onToggle,
onSelectAll,
onDeselectAll,
language = 'de',
showFilters = true,
readOnly = false,
}: DataPointCatalogProps) {
const [searchQuery, setSearchQuery] = useState('')
const [expandedCategories, setExpandedCategories] = useState<Set<DataPointCategory>>(
new Set(ALL_CATEGORIES)
)
const [filterCategory, setFilterCategory] = useState<DataPointCategory | 'ALL'>('ALL')
const [filterRisk, setFilterRisk] = useState<RiskLevel | 'ALL'>('ALL')
const [filterBasis, setFilterBasis] = useState<LegalBasis | 'ALL'>('ALL')
const [dismissedWarnings, setDismissedWarnings] = useState<Set<DataPointCategory>>(new Set())
const filteredDataPoints = useMemo(() => {
let result = dataPoints
if (searchQuery.trim()) {
result = searchDataPoints(result, searchQuery, language)
}
if (filterCategory !== 'ALL') {
result = result.filter((dp) => dp.category === filterCategory)
}
if (filterRisk !== 'ALL') {
result = result.filter((dp) => dp.riskLevel === filterRisk)
}
if (filterBasis !== 'ALL') {
result = result.filter((dp) => dp.legalBasis === filterBasis)
}
return result
}, [dataPoints, searchQuery, filterCategory, filterRisk, filterBasis, language])
const groupedDataPoints = useMemo(() => {
const grouped = new Map<DataPointCategory, DataPoint[]>()
for (const cat of ALL_CATEGORIES) {
grouped.set(cat, [])
}
for (const dp of filteredDataPoints) {
const existing = grouped.get(dp.category) || []
grouped.set(dp.category, [...existing, dp])
}
return grouped
}, [filteredDataPoints])
const selectedSpecialCategories = useMemo(() => {
const special = new Set<DataPointCategory>()
for (const id of selectedIds) {
const dp = dataPoints.find(d => d.id === id)
if (dp) {
if (dp.category === 'HEALTH_DATA' || dp.isSpecialCategory) {
special.add('HEALTH_DATA')
}
if (dp.category === 'EMPLOYEE_DATA') {
special.add('EMPLOYEE_DATA')
}
if (dp.category === 'AI_DATA') {
special.add('AI_DATA')
}
}
}
return special
}, [selectedIds, dataPoints])
const toggleCategory = (category: DataPointCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
}
const totalSelected = selectedIds.length
const totalDataPoints = dataPoints.length
return (
<div className="space-y-4">
{/* Header with Stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-slate-600">
<span className="font-semibold text-slate-900">{totalSelected}</span> von{' '}
<span className="font-semibold">{totalDataPoints}</span> Datenpunkte ausgewaehlt
</div>
</div>
{!readOnly && (
<div className="flex items-center gap-2">
<button
onClick={onSelectAll}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium"
>
Alle auswaehlen
</button>
<span className="text-slate-300">|</span>
<button
onClick={onDeselectAll}
className="text-sm text-slate-600 hover:text-slate-700 font-medium"
>
Auswahl aufheben
</button>
</div>
)}
</div>
{/* Art. 9 / BDSG § 26 / AI Act Warnungen */}
{selectedSpecialCategories.size > 0 && (
<div className="space-y-3">
{selectedSpecialCategories.has('HEALTH_DATA') && !dismissedWarnings.has('HEALTH_DATA') && (
<SpecialCategoryWarning
category="HEALTH_DATA"
language={language}
onClose={() => setDismissedWarnings(prev => new Set([...prev, 'HEALTH_DATA']))}
/>
)}
{selectedSpecialCategories.has('EMPLOYEE_DATA') && !dismissedWarnings.has('EMPLOYEE_DATA') && (
<SpecialCategoryWarning
category="EMPLOYEE_DATA"
language={language}
onClose={() => setDismissedWarnings(prev => new Set([...prev, 'EMPLOYEE_DATA']))}
/>
)}
{selectedSpecialCategories.has('AI_DATA') && !dismissedWarnings.has('AI_DATA') && (
<SpecialCategoryWarning
category="AI_DATA"
language={language}
onClose={() => setDismissedWarnings(prev => new Set([...prev, 'AI_DATA']))}
/>
)}
</div>
)}
{/* Search and Filters */}
{showFilters && (
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Datenpunkte suchen..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
/>
</div>
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value as DataPointCategory | 'ALL')}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="ALL">Alle Kategorien</option>
{Object.entries(CATEGORY_METADATA).map(([key, meta]) => (
<option key={key} value={key}>
{meta.name[language]}
</option>
))}
</select>
<select
value={filterRisk}
onChange={(e) => setFilterRisk(e.target.value as RiskLevel | 'ALL')}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="ALL">Alle Risikostufen</option>
<option value="LOW">{RISK_LEVEL_STYLING.LOW.label[language]}</option>
<option value="MEDIUM">{RISK_LEVEL_STYLING.MEDIUM.label[language]}</option>
<option value="HIGH">{RISK_LEVEL_STYLING.HIGH.label[language]}</option>
</select>
<select
value={filterBasis}
onChange={(e) => setFilterBasis(e.target.value as LegalBasis | 'ALL')}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="ALL">Alle Rechtsgrundlagen</option>
{Object.entries(LEGAL_BASIS_INFO).map(([key, info]) => (
<option key={key} value={key}>
{info.name[language]}
</option>
))}
</select>
</div>
)}
{/* Data Points by Category */}
<div className="space-y-3">
{Array.from(groupedDataPoints.entries()).map(([category, categoryDataPoints]) => {
if (categoryDataPoints.length === 0) return null
return (
<DataPointCategoryGroup
key={category}
category={category}
categoryDataPoints={categoryDataPoints}
selectedIds={selectedIds}
isExpanded={expandedCategories.has(category)}
readOnly={readOnly}
language={language}
onToggleCategory={toggleCategory}
onToggleDataPoint={onToggle}
/>
)
})}
</div>
{/* Empty State */}
{filteredDataPoints.length === 0 && (
<div className="text-center py-12 text-slate-500">
<Search className="w-12 h-12 mx-auto mb-4 text-slate-300" />
<p className="font-medium">Keine Datenpunkte gefunden</p>
<p className="text-sm mt-1">Versuchen Sie andere Suchbegriffe oder Filter</p>
</div>
)}
</div>
)
}
export default DataPointCatalog