fix(admin-v2): Restore complete admin-v2 application

The admin-v2 application was incomplete in the repository. This commit
restores all missing components:

- Admin pages (76 pages): dashboard, ai, compliance, dsgvo, education,
  infrastructure, communication, development, onboarding, rbac
- SDK pages (45 pages): tom, dsfa, vvt, loeschfristen, einwilligungen,
  vendor-compliance, tom-generator, dsr, and more
- Developer portal (25 pages): API docs, SDK guides, frameworks
- All components, lib files, hooks, and types
- Updated package.json with all dependencies

The issue was caused by incomplete initial repository state - the full
admin-v2 codebase existed in backend/admin-v2 and docs-src/admin-v2
but was never fully synced to the main admin-v2 directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-08 23:40:15 -08:00
parent f28244753f
commit 660295e218
385 changed files with 138126 additions and 3079 deletions

View File

@@ -0,0 +1,658 @@
'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,
Filter,
CheckCircle,
Circle,
Shield,
AlertTriangle,
ChevronDown,
ChevronRight,
Key,
Megaphone,
MessageSquare,
CreditCard,
Users,
Bot,
Lock,
User,
Mail,
Activity,
MapPin,
Smartphone,
BarChart3,
Share2,
Heart,
Briefcase,
FileText,
FileCode,
Info,
X,
} from 'lucide-react'
import {
DataPoint,
DataPointCategory,
RiskLevel,
LegalBasis,
SupportedLanguage,
CATEGORY_METADATA,
RISK_LEVEL_STYLING,
LEGAL_BASIS_INFO,
RETENTION_PERIOD_INFO,
ARTICLE_9_WARNING,
EMPLOYEE_DATA_WARNING,
AI_DATA_WARNING,
} from '@/lib/sdk/einwilligungen/types'
import { searchDataPoints } from '@/lib/sdk/einwilligungen/catalog/loader'
// =============================================================================
// TYPES
// =============================================================================
interface DataPointCatalogProps {
dataPoints: DataPoint[]
selectedIds: string[]
onToggle: (id: string) => void
onSelectAll?: () => void
onDeselectAll?: () => void
language?: SupportedLanguage
showFilters?: boolean
readOnly?: boolean
}
// =============================================================================
// HELPER COMPONENTS
// =============================================================================
const CategoryIcon: React.FC<{ category: DataPointCategory; className?: string }> = ({
category,
className = 'w-5 h-5',
}) => {
const icons: Record<DataPointCategory, React.ReactNode> = {
// 18 Kategorien (A-R)
MASTER_DATA: <User className={className} />,
CONTACT_DATA: <Mail className={className} />,
AUTHENTICATION: <Key className={className} />,
CONSENT: <CheckCircle className={className} />,
COMMUNICATION: <MessageSquare className={className} />,
PAYMENT: <CreditCard className={className} />,
USAGE_DATA: <Activity className={className} />,
LOCATION: <MapPin className={className} />,
DEVICE_DATA: <Smartphone className={className} />,
MARKETING: <Megaphone className={className} />,
ANALYTICS: <BarChart3 className={className} />,
SOCIAL_MEDIA: <Share2 className={className} />,
HEALTH_DATA: <Heart className={className} />,
EMPLOYEE_DATA: <Briefcase className={className} />,
CONTRACT_DATA: <FileText className={className} />,
LOG_DATA: <FileCode className={className} />,
AI_DATA: <Bot className={className} />,
SECURITY: <Shield className={className} />,
}
return <>{icons[category] || <Circle className={className} />}</>
}
const RiskBadge: React.FC<{ level: RiskLevel; language: SupportedLanguage }> = ({
level,
language,
}) => {
const styling = RISK_LEVEL_STYLING[level]
return (
<span
className={`px-2 py-0.5 text-xs font-medium rounded-full ${styling.bgColor} ${styling.color}`}
>
{styling.label[language]}
</span>
)
}
const LegalBasisBadge: React.FC<{ basis: LegalBasis; language: SupportedLanguage }> = ({
basis,
language,
}) => {
const info = LEGAL_BASIS_INFO[basis]
const colors: Record<LegalBasis, string> = {
CONTRACT: 'bg-blue-100 text-blue-700',
CONSENT: 'bg-purple-100 text-purple-700',
EXPLICIT_CONSENT: 'bg-rose-100 text-rose-700',
LEGITIMATE_INTEREST: 'bg-amber-100 text-amber-700',
LEGAL_OBLIGATION: 'bg-slate-100 text-slate-700',
VITAL_INTERESTS: 'bg-emerald-100 text-emerald-700',
PUBLIC_INTEREST: 'bg-cyan-100 text-cyan-700',
}
return (
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${colors[basis] || 'bg-gray-100 text-gray-700'}`}>
{info?.name[language] || basis}
</span>
)
}
/**
* Warnung fuer besondere Kategorien (Art. 9 DSGVO, BDSG § 26, AI Act)
*/
const SpecialCategoryWarning: React.FC<{
category: DataPointCategory
language: SupportedLanguage
onClose?: () => void
}> = ({ category, language, onClose }) => {
// Bestimme welche Warnung angezeigt werden soll
let warning = null
let bgColor = ''
let borderColor = ''
let iconColor = ''
if (category === 'HEALTH_DATA') {
warning = ARTICLE_9_WARNING
bgColor = 'bg-rose-50'
borderColor = 'border-rose-200'
iconColor = 'text-rose-600'
} else if (category === 'EMPLOYEE_DATA') {
warning = EMPLOYEE_DATA_WARNING
bgColor = 'bg-orange-50'
borderColor = 'border-orange-200'
iconColor = 'text-orange-600'
} else if (category === 'AI_DATA') {
warning = AI_DATA_WARNING
bgColor = 'bg-fuchsia-50'
borderColor = 'border-fuchsia-200'
iconColor = 'text-fuchsia-600'
}
if (!warning) return null
return (
<div className={`p-4 rounded-lg border ${bgColor} ${borderColor} mb-4`}>
<div className="flex items-start gap-3">
<AlertTriangle className={`w-5 h-5 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1">
<div className="flex items-center justify-between">
<h4 className={`font-semibold ${iconColor}`}>
{warning.title[language]}
</h4>
{onClose && (
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-600 transition-colors"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<p className="text-sm text-slate-600 mt-1">
{warning.description[language]}
</p>
<ul className="mt-3 space-y-1">
{warning.requirements.map((req, idx) => (
<li key={idx} className="text-sm text-slate-700 flex items-start gap-2">
<span className={`${iconColor} font-bold`}></span>
<span>{req[language]}</span>
</li>
))}
</ul>
</div>
</div>
</div>
)
}
/**
* Inline-Hinweis fuer Art. 9 Datenpunkte
*/
const Article9Badge: React.FC<{ language: SupportedLanguage }> = ({ language }) => (
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-rose-100 text-rose-700 border border-rose-200">
<Heart className="w-3 h-3" />
{language === 'de' ? 'Art. 9 DSGVO' : 'Art. 9 GDPR'}
</span>
)
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function DataPointCatalog({
dataPoints,
selectedIds,
onToggle,
onSelectAll,
onDeselectAll,
language = 'de',
showFilters = true,
readOnly = false,
}: DataPointCatalogProps) {
// Alle 18 Kategorien in der richtigen Reihenfolge (A-R)
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
]
// State
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())
// Filtered and searched data points
const filteredDataPoints = useMemo(() => {
let result = dataPoints
// Search
if (searchQuery.trim()) {
result = searchDataPoints(result, searchQuery, language)
}
// Filter by category
if (filterCategory !== 'ALL') {
result = result.filter((dp) => dp.category === filterCategory)
}
// Filter by risk
if (filterRisk !== 'ALL') {
result = result.filter((dp) => dp.riskLevel === filterRisk)
}
// Filter by legal basis
if (filterBasis !== 'ALL') {
result = result.filter((dp) => dp.legalBasis === filterBasis)
}
return result
}, [dataPoints, searchQuery, filterCategory, filterRisk, filterBasis, language])
// Group by category (18 Kategorien)
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])
// Zaehle ausgewaehlte spezielle Kategorien fuer Warnungen
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])
// Toggle category expansion
const toggleCategory = (category: DataPointCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
}
// Stats
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 DSGVO / 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">
{/* Search */}
<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>
{/* Category Filter */}
<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>
{/* Risk Filter */}
<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>
{/* Legal Basis Filter */}
<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
const meta = CATEGORY_METADATA[category]
const isExpanded = expandedCategories.has(category)
const selectedInCategory = categoryDataPoints.filter((dp) =>
selectedIds.includes(dp.id)
).length
return (
<div
key={category}
className="border border-slate-200 rounded-xl overflow-hidden bg-white"
>
{/* Category Header */}
<button
onClick={() => toggleCategory(category)}
className={`w-full flex items-center justify-between px-4 py-3 transition-colors ${
category === 'HEALTH_DATA'
? 'bg-rose-50 hover:bg-rose-100 border-l-4 border-rose-400'
: category === 'EMPLOYEE_DATA'
? 'bg-orange-50 hover:bg-orange-100 border-l-4 border-orange-400'
: category === 'AI_DATA'
? 'bg-fuchsia-50 hover:bg-fuchsia-100 border-l-4 border-fuchsia-400'
: 'bg-slate-50 hover:bg-slate-100'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
category === 'HEALTH_DATA' ? 'bg-rose-100' :
category === 'EMPLOYEE_DATA' ? 'bg-orange-100' :
category === 'AI_DATA' ? 'bg-fuchsia-100' :
`bg-${meta.color}-100`
}`}>
<CategoryIcon
category={category}
className={`w-5 h-5 ${
category === 'HEALTH_DATA' ? 'text-rose-600' :
category === 'EMPLOYEE_DATA' ? 'text-orange-600' :
category === 'AI_DATA' ? 'text-fuchsia-600' :
`text-${meta.color}-600`
}`}
/>
</div>
<div className="text-left">
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900">
{meta.code}. {meta.name[language]}
</span>
{category === 'HEALTH_DATA' && (
<span className="text-xs bg-rose-200 text-rose-700 px-1.5 py-0.5 rounded font-medium">
Art. 9 DSGVO
</span>
)}
{category === 'EMPLOYEE_DATA' && (
<span className="text-xs bg-orange-200 text-orange-700 px-1.5 py-0.5 rounded font-medium">
BDSG § 26
</span>
)}
{category === 'AI_DATA' && (
<span className="text-xs bg-fuchsia-200 text-fuchsia-700 px-1.5 py-0.5 rounded font-medium">
AI Act
</span>
)}
</div>
<div className="text-xs text-slate-500">{meta.description[language]}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">
{selectedInCategory}/{categoryDataPoints.length}
</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-slate-400" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400" />
)}
</div>
</button>
{/* Data Points List */}
{isExpanded && (
<div className="divide-y divide-slate-100">
{categoryDataPoints.map((dp) => {
const isSelected = selectedIds.includes(dp.id)
return (
<div
key={dp.id}
className={`flex items-start gap-4 p-4 ${
readOnly ? '' : 'cursor-pointer hover:bg-slate-50'
} transition-colors ${isSelected ? 'bg-indigo-50/50' : ''}`}
onClick={() => !readOnly && onToggle(dp.id)}
>
{/* Checkbox */}
{!readOnly && (
<div className="flex-shrink-0 pt-0.5">
{isSelected ? (
<CheckCircle className="w-5 h-5 text-indigo-600" />
) : (
<Circle className="w-5 h-5 text-slate-300" />
)}
</div>
)}
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{dp.code}
</span>
<span className="font-medium text-slate-900">
{dp.name[language]}
</span>
{dp.isSpecialCategory && (
<Article9Badge language={language} />
)}
{dp.isCustom && (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
{language === 'de' ? 'Benutzerdefiniert' : 'Custom'}
</span>
)}
</div>
<p className="text-sm text-slate-600 mt-1">
{dp.description[language]}
</p>
</div>
<div className="flex-shrink-0 flex flex-col items-end gap-1">
<RiskBadge level={dp.riskLevel} language={language} />
<LegalBasisBadge basis={dp.legalBasis} language={language} />
</div>
</div>
{/* Details */}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-500">
<span>
<strong>{language === 'de' ? 'Zweck' : 'Purpose'}:</strong> {dp.purpose[language]}
</span>
<span>
<strong>{language === 'de' ? 'Loeschfrist' : 'Retention'}:</strong>{' '}
{RETENTION_PERIOD_INFO[dp.retentionPeriod]?.label[language] || dp.retentionPeriod}
</span>
{dp.cookieCategory && (
<span>
<strong>Cookie:</strong> {dp.cookieCategory}
</span>
)}
</div>
{/* Spezielle Warnungen fuer Art. 9 / BDSG / AI Act */}
{(dp.requiresExplicitConsent || dp.isSpecialCategory) && (
<div className="mt-2 p-2 rounded-md bg-rose-50 border border-rose-200">
<div className="flex items-start gap-2 text-xs text-rose-700">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<div>
<strong>
{language === 'de'
? 'Ausdrueckliche Einwilligung erforderlich'
: 'Explicit consent required'}
</strong>
{dp.legalBasis === 'EXPLICIT_CONSENT' && (
<span className="block mt-1 text-rose-600">
{language === 'de'
? 'Art. 9 Abs. 2 lit. a DSGVO - Separate Einwilligungserklaerung notwendig'
: 'Art. 9(2)(a) GDPR - Separate consent declaration required'}
</span>
)}
</div>
</div>
</div>
)}
{/* Third Party Recipients */}
{dp.thirdPartyRecipients.length > 0 && (
<div className="mt-2 text-xs text-slate-500">
<strong>Drittanbieter:</strong>{' '}
{dp.thirdPartyRecipients.join(', ')}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</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

View File

@@ -0,0 +1,321 @@
'use client'
/**
* PrivacyPolicyPreview Component
*
* Zeigt eine Vorschau der generierten Datenschutzerklaerung.
*/
import { useState } from 'react'
import {
FileText,
Download,
Globe,
Eye,
Code,
ChevronDown,
ChevronRight,
Copy,
Check,
} from 'lucide-react'
import {
GeneratedPrivacyPolicy,
PrivacyPolicySection,
SupportedLanguage,
ExportFormat,
} from '@/lib/sdk/einwilligungen/types'
// =============================================================================
// TYPES
// =============================================================================
interface PrivacyPolicyPreviewProps {
policy: GeneratedPrivacyPolicy | null
isLoading?: boolean
language: SupportedLanguage
format: ExportFormat
onLanguageChange: (language: SupportedLanguage) => void
onFormatChange: (format: ExportFormat) => void
onGenerate: () => void
onDownload?: (format: ExportFormat) => void
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function PrivacyPolicyPreview({
policy,
isLoading = false,
language,
format,
onLanguageChange,
onFormatChange,
onGenerate,
onDownload,
}: PrivacyPolicyPreviewProps) {
const [viewMode, setViewMode] = useState<'preview' | 'source'>('preview')
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const [copied, setCopied] = useState(false)
const toggleSection = (sectionId: string) => {
setExpandedSections((prev) => {
const next = new Set(prev)
if (next.has(sectionId)) {
next.delete(sectionId)
} else {
next.add(sectionId)
}
return next
})
}
const expandAll = () => {
if (policy) {
setExpandedSections(new Set(policy.sections.map((s) => s.id)))
}
}
const collapseAll = () => {
setExpandedSections(new Set())
}
const copyToClipboard = async () => {
if (policy?.content) {
await navigator.clipboard.writeText(policy.content)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-slate-50 rounded-xl">
<div className="flex items-center gap-3">
{/* Language Selector */}
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-slate-400" />
<select
value={language}
onChange={(e) => onLanguageChange(e.target.value as SupportedLanguage)}
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
{/* Format Selector */}
<select
value={format}
onChange={(e) => onFormatChange(e.target.value as ExportFormat)}
className="px-3 py-1.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="HTML">HTML</option>
<option value="MARKDOWN">Markdown</option>
<option value="PDF">PDF</option>
<option value="DOCX">Word (DOCX)</option>
</select>
{/* View Mode Toggle */}
<div className="flex items-center border border-slate-300 rounded-lg overflow-hidden">
<button
onClick={() => setViewMode('preview')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${
viewMode === 'preview'
? 'bg-indigo-600 text-white'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<Eye className="w-4 h-4" />
Vorschau
</button>
<button
onClick={() => setViewMode('source')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm ${
viewMode === 'source'
? 'bg-indigo-600 text-white'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<Code className="w-4 h-4" />
Quelltext
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onGenerate}
disabled={isLoading}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Generiere...
</>
) : (
<>
<FileText className="w-4 h-4" />
Generieren
</>
)}
</button>
{policy && onDownload && (
<button
onClick={() => onDownload(format)}
className="flex items-center gap-2 px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50"
>
<Download className="w-4 h-4" />
Download
</button>
)}
</div>
</div>
{/* Content */}
{policy ? (
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-slate-50 border-b border-slate-200">
<div>
<h3 className="font-semibold text-slate-900">
{language === 'de' ? 'Datenschutzerklaerung' : 'Privacy Policy'}
</h3>
<p className="text-xs text-slate-500">
Version {policy.version} |{' '}
{new Date(policy.generatedAt).toLocaleDateString(
language === 'de' ? 'de-DE' : 'en-US'
)}
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={expandAll}
className="text-xs text-indigo-600 hover:text-indigo-700"
>
Alle aufklappen
</button>
<span className="text-slate-300">|</span>
<button
onClick={collapseAll}
className="text-xs text-slate-600 hover:text-slate-700"
>
Alle zuklappen
</button>
{viewMode === 'source' && (
<>
<span className="text-slate-300">|</span>
<button
onClick={copyToClipboard}
className="flex items-center gap-1 text-xs text-slate-600 hover:text-slate-700"
>
{copied ? (
<>
<Check className="w-3 h-3 text-green-600" />
Kopiert
</>
) : (
<>
<Copy className="w-3 h-3" />
Kopieren
</>
)}
</button>
</>
)}
</div>
</div>
{/* Sections */}
{viewMode === 'preview' ? (
<div className="divide-y divide-slate-100">
{policy.sections.map((section) => {
const isExpanded = expandedSections.has(section.id)
return (
<div key={section.id}>
<button
onClick={() => toggleSection(section.id)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-slate-50 transition-colors"
>
<span className="font-medium text-slate-900">
{section.title[language]}
</span>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-slate-400" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400" />
)}
</button>
{isExpanded && (
<div className="px-4 pb-4">
<div
className="prose prose-sm prose-slate max-w-none"
dangerouslySetInnerHTML={{
__html: formatContent(section.content[language]),
}}
/>
{section.isGenerated && (
<div className="mt-2 text-xs text-slate-400 flex items-center gap-1">
<span className="w-2 h-2 bg-green-400 rounded-full" />
Automatisch aus Datenpunkten generiert
</div>
)}
</div>
)}
</div>
)
})}
</div>
) : (
<div className="p-4">
<pre className="bg-slate-900 text-slate-100 p-4 rounded-lg overflow-x-auto text-sm font-mono whitespace-pre-wrap">
{policy.content}
</pre>
</div>
)}
</div>
) : (
<div className="border border-slate-200 rounded-xl p-12 text-center bg-white">
<FileText className="w-16 h-16 mx-auto mb-4 text-slate-300" />
<h3 className="font-semibold text-slate-900 mb-2">
Keine Datenschutzerklaerung generiert
</h3>
<p className="text-sm text-slate-500 mb-4">
Waehlen Sie die gewuenschten Datenpunkte aus und klicken Sie auf "Generieren", um eine
Datenschutzerklaerung zu erstellen.
</p>
<button
onClick={onGenerate}
disabled={isLoading}
className="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700"
>
<FileText className="w-4 h-4" />
Jetzt generieren
</button>
</div>
)}
</div>
)
}
/**
* Formatiert Markdown-aehnlichen Content zu HTML
*/
function formatContent(content: string): string {
return content
.replace(/### (.+)/g, '<h4 class="font-semibold text-slate-800 mt-4 mb-2">$1</h4>')
.replace(/## (.+)/g, '<h3 class="font-semibold text-lg text-slate-900 mt-6 mb-3">$1</h3>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\n\n/g, '</p><p class="mb-3">')
.replace(/\n- /g, '</p><ul class="list-disc pl-5 mb-3"><li>')
.replace(/<li>(.+?)(?=<li>|<\/p>|$)/g, '<li class="mb-1">$1</li>')
.replace(/(<li[^>]*>.*?<\/li>)+/g, '<ul class="list-disc pl-5 mb-3">$&</ul>')
.replace(/<\/ul><ul[^>]*>/g, '')
.replace(/\n/g, '<br>')
}
export default PrivacyPolicyPreview

View File

@@ -0,0 +1,350 @@
'use client'
/**
* RetentionMatrix Component
*
* Visualisiert die Loeschfristen-Matrix nach Kategorien.
*/
import { useState, useMemo } from 'react'
import {
Clock,
Calendar,
Info,
AlertTriangle,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import {
RetentionMatrixEntry,
RetentionPeriod,
DataPointCategory,
SupportedLanguage,
CATEGORY_METADATA,
RETENTION_PERIOD_INFO,
DataPoint,
} from '@/lib/sdk/einwilligungen/types'
// =============================================================================
// TYPES
// =============================================================================
interface RetentionMatrixProps {
matrix: RetentionMatrixEntry[]
dataPoints: DataPoint[]
language?: SupportedLanguage
showDetails?: boolean
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getRetentionColor(period: RetentionPeriod): string {
const days = RETENTION_PERIOD_INFO[period].days
if (days === null) return 'bg-purple-100 text-purple-700'
if (days <= 30) return 'bg-green-100 text-green-700'
if (days <= 365) return 'bg-blue-100 text-blue-700'
if (days <= 1095) return 'bg-amber-100 text-amber-700'
return 'bg-red-100 text-red-700'
}
function getRetentionBarWidth(period: RetentionPeriod): number {
const days = RETENTION_PERIOD_INFO[period].days
if (days === null) return 100
const maxDays = 3650 // 10 Jahre
return Math.min(100, (days / maxDays) * 100)
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function RetentionMatrix({
matrix,
dataPoints,
language = 'de',
showDetails = true,
}: RetentionMatrixProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<DataPointCategory>>(new Set())
const toggleCategory = (category: DataPointCategory) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
}
// Group data points by category
const dataPointsByCategory = useMemo(() => {
const grouped = new Map<DataPointCategory, DataPoint[]>()
for (const dp of dataPoints) {
const existing = grouped.get(dp.category) || []
grouped.set(dp.category, [...existing, dp])
}
return grouped
}, [dataPoints])
// Stats
const stats = useMemo(() => {
const periodCounts: Record<string, number> = {}
for (const dp of dataPoints) {
periodCounts[dp.retentionPeriod] = (periodCounts[dp.retentionPeriod] || 0) + 1
}
return periodCounts
}, [dataPoints])
return (
<div className="space-y-6">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-green-50 rounded-xl p-4">
<div className="flex items-center gap-2 text-green-600 mb-1">
<Clock className="w-4 h-4" />
<span className="text-sm font-medium">Kurzfristig</span>
</div>
<div className="text-2xl font-bold text-green-700">
{(stats['24_HOURS'] || 0) + (stats['30_DAYS'] || 0)}
</div>
<div className="text-xs text-green-600"> 30 Tage</div>
</div>
<div className="bg-blue-50 rounded-xl p-4">
<div className="flex items-center gap-2 text-blue-600 mb-1">
<Calendar className="w-4 h-4" />
<span className="text-sm font-medium">Mittelfristig</span>
</div>
<div className="text-2xl font-bold text-blue-700">
{(stats['90_DAYS'] || 0) + (stats['12_MONTHS'] || 0)}
</div>
<div className="text-xs text-blue-600">90 Tage - 12 Monate</div>
</div>
<div className="bg-amber-50 rounded-xl p-4">
<div className="flex items-center gap-2 text-amber-600 mb-1">
<Calendar className="w-4 h-4" />
<span className="text-sm font-medium">Langfristig</span>
</div>
<div className="text-2xl font-bold text-amber-700">
{(stats['24_MONTHS'] || 0) + (stats['36_MONTHS'] || 0)}
</div>
<div className="text-xs text-amber-600">2-3 Jahre</div>
</div>
<div className="bg-red-50 rounded-xl p-4">
<div className="flex items-center gap-2 text-red-600 mb-1">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">Gesetzlich</span>
</div>
<div className="text-2xl font-bold text-red-700">
{(stats['6_YEARS'] || 0) + (stats['10_YEARS'] || 0)}
</div>
<div className="text-xs text-red-600">6-10 Jahre (AO/HGB)</div>
</div>
</div>
{/* Matrix Table */}
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-4 py-3 text-sm font-semibold text-slate-700">
Kategorie
</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-slate-700">
Standard-Loeschfrist
</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-slate-700 hidden md:table-cell">
Rechtsgrundlage
</th>
<th className="text-center px-4 py-3 text-sm font-semibold text-slate-700 w-24">
Datenpunkte
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{matrix.map((entry) => {
const meta = CATEGORY_METADATA[entry.category]
const categoryDataPoints = dataPointsByCategory.get(entry.category) || []
const isExpanded = expandedCategories.has(entry.category)
return (
<>
<tr
key={entry.category}
className="hover:bg-slate-50 cursor-pointer transition-colors"
onClick={() => showDetails && toggleCategory(entry.category)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{showDetails && (
<div className="text-slate-400">
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</div>
)}
<div>
<div className="font-medium text-slate-900">
{meta.code}. {entry.categoryName[language]}
</div>
{entry.exceptions.length > 0 && (
<div className="text-xs text-slate-500">
{entry.exceptions.length} Ausnahme(n)
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span
className={`inline-flex px-2.5 py-1 text-xs font-medium rounded-full w-fit ${getRetentionColor(
entry.standardPeriod
)}`}
>
{RETENTION_PERIOD_INFO[entry.standardPeriod].label[language]}
</span>
<div className="h-1.5 bg-slate-100 rounded-full w-full max-w-[200px]">
<div
className={`h-full rounded-full ${
getRetentionColor(entry.standardPeriod).includes('green')
? 'bg-green-400'
: getRetentionColor(entry.standardPeriod).includes('blue')
? 'bg-blue-400'
: getRetentionColor(entry.standardPeriod).includes('amber')
? 'bg-amber-400'
: getRetentionColor(entry.standardPeriod).includes('red')
? 'bg-red-400'
: 'bg-purple-400'
}`}
style={{ width: `${getRetentionBarWidth(entry.standardPeriod)}%` }}
/>
</div>
</div>
</td>
<td className="px-4 py-3 text-sm text-slate-600 hidden md:table-cell">
{entry.legalBasis}
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-slate-100 text-sm font-medium text-slate-700">
{categoryDataPoints.length}
</span>
</td>
</tr>
{/* Expanded Details */}
{showDetails && isExpanded && (
<tr key={`${entry.category}-details`}>
<td colSpan={4} className="bg-slate-50 px-4 py-4">
<div className="space-y-4">
{/* Exceptions */}
{entry.exceptions.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-700 mb-2 flex items-center gap-1">
<Info className="w-4 h-4" />
Ausnahmen von der Standardfrist
</h4>
<div className="space-y-2">
{entry.exceptions.map((exc, idx) => (
<div
key={idx}
className="flex items-start gap-3 p-3 bg-white rounded-lg border border-slate-200"
>
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5" />
<div>
<div className="text-sm font-medium text-slate-900">
{exc.condition[language]}
</div>
<div className="text-sm text-slate-600">
Loeschfrist:{' '}
<span className="font-medium">
{RETENTION_PERIOD_INFO[exc.period].label[language]}
</span>
</div>
<div className="text-xs text-slate-500 mt-1">
{exc.reason[language]}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Data Points in Category */}
{categoryDataPoints.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-700 mb-2">
Datenpunkte in dieser Kategorie
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{categoryDataPoints.map((dp) => (
<div
key={dp.id}
className="flex items-center justify-between p-2 bg-white rounded-lg border border-slate-200"
>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{dp.code}
</span>
<span className="text-sm text-slate-700">
{dp.name[language]}
</span>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full ${getRetentionColor(
dp.retentionPeriod
)}`}
>
{RETENTION_PERIOD_INFO[dp.retentionPeriod].label[language]}
</span>
</div>
))}
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-600">
<span className="font-medium">Legende:</span>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-green-400" />
30 Tage
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-400" />
90 Tage - 12 Monate
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-amber-400" />
2-3 Jahre
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400" />
6-10 Jahre
</div>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-purple-400" />
Variabel
</div>
</div>
</div>
)
}
export default RetentionMatrix

View File

@@ -0,0 +1,9 @@
/**
* Einwilligungen Components
*
* UI-Komponenten fuer das Datenpunktkatalog & DSI-Generator Modul.
*/
export { DataPointCatalog } from './DataPointCatalog'
export { PrivacyPolicyPreview } from './PrivacyPolicyPreview'
export { RetentionMatrix } from './RetentionMatrix'