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>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import type { ProjectInfo, CustomerType } from '@/lib/sdk/types'
|
||||
|
||||
/** Map snake_case backend response to camelCase ProjectInfo */
|
||||
export function normalizeProject(p: any): ProjectInfo {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
customerType: p.customerType || p.customer_type || 'new',
|
||||
status: p.status || 'active',
|
||||
projectVersion: p.projectVersion ?? p.project_version ?? 1,
|
||||
completionPercentage: p.completionPercentage ?? p.completion_percentage ?? 0,
|
||||
createdAt: p.createdAt || p.created_at || '',
|
||||
updatedAt: p.updatedAt || p.updated_at || '',
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateProjectDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: (project: ProjectInfo) => void
|
||||
existingProjects: ProjectInfo[]
|
||||
}
|
||||
|
||||
export function CreateProjectDialog({ open, onClose, onCreated, existingProjects }: CreateProjectDialogProps) {
|
||||
const { createProject } = useSDK()
|
||||
const [name, setName] = useState('')
|
||||
const [customerType, setCustomerType] = useState<CustomerType>('new')
|
||||
const [copyFromId, setCopyFromId] = useState<string>('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
setError('Projektname ist erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const project = await createProject(
|
||||
name.trim(),
|
||||
customerType,
|
||||
copyFromId || undefined
|
||||
)
|
||||
onCreated(normalizeProject(project))
|
||||
setName('')
|
||||
setCopyFromId('')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Projekts')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Neues Projekt erstellen</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Projektname *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="z.B. KI-Produkt X, SaaS API, Tochter GmbH..."
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Projekttyp
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomerType('new')}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||||
customerType === 'new'
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900">Neukunde</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">Compliance von Grund auf</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomerType('existing')}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||||
customerType === 'existing'
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900">Bestandskunde</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">Bestehende Dokumente erweitern</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{existingProjects.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Stammdaten kopieren von (optional)
|
||||
</label>
|
||||
<select
|
||||
value={copyFromId}
|
||||
onChange={e => setCopyFromId(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none bg-white"
|
||||
>
|
||||
<option value="">— Keine Kopie (leeres Projekt) —</option>
|
||||
{existingProjects.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} (V{String(p.projectVersion).padStart(3, '0')})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Firmenprofil wird kopiert und kann dann unabhaengig bearbeitet werden.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:bg-purple-300 rounded-lg transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Erstelle...' : 'Projekt erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import type { ProjectInfo } from '@/lib/sdk/types'
|
||||
|
||||
type ActionStep = 'choose' | 'confirm-delete'
|
||||
|
||||
interface ProjectActionDialogProps {
|
||||
project: ProjectInfo
|
||||
onArchive: () => void
|
||||
onPermanentDelete: () => void
|
||||
onCancel: () => void
|
||||
isProcessing: boolean
|
||||
}
|
||||
|
||||
export function ProjectActionDialog({
|
||||
project,
|
||||
onArchive,
|
||||
onPermanentDelete,
|
||||
onCancel,
|
||||
isProcessing,
|
||||
}: ProjectActionDialogProps) {
|
||||
const [step, setStep] = useState<ActionStep>('choose')
|
||||
|
||||
if (step === 'confirm-delete') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-red-700">Endgueltig loeschen</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-red-800 font-medium mb-2">
|
||||
Sind Sie sicher, dass Sie <strong>{project.name}</strong> unwiderruflich loeschen moechten?
|
||||
</p>
|
||||
<p className="text-sm text-red-700">
|
||||
Alle Projektdaten, SDK-States und Dokumente werden permanent geloescht. Diese Aktion kann nicht rueckgaengig gemacht werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep('choose')}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<button
|
||||
onClick={onPermanentDelete}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-red-700 hover:bg-red-800 disabled:bg-red-300 rounded-lg transition-colors"
|
||||
>
|
||||
{isProcessing ? 'Loesche...' : 'Endgueltig loeschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900">Projekt entfernen</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Was moechten Sie mit dem Projekt <strong>{project.name}</strong> tun?
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<button
|
||||
onClick={onArchive}
|
||||
disabled={isProcessing}
|
||||
className="w-full flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all text-left disabled:opacity-50"
|
||||
>
|
||||
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Archivieren</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Projekt wird ausgeblendet, Daten bleiben erhalten. Kann jederzeit wiederhergestellt werden.
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setStep('confirm-delete')}
|
||||
disabled={isProcessing}
|
||||
className="w-full flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-red-300 hover:bg-red-50 transition-all text-left disabled:opacity-50"
|
||||
>
|
||||
<div className="w-8 h-8 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-red-700">Endgueltig loeschen</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Alle Daten werden unwiderruflich geloescht. Diese Aktion kann nicht rueckgaengig gemacht werden.
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
128
admin-compliance/components/sdk/ProjectSelector/ProjectCard.tsx
Normal file
128
admin-compliance/components/sdk/ProjectSelector/ProjectCard.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { ProjectInfo } from '@/lib/sdk/types'
|
||||
|
||||
export function formatTimeAgo(dateStr: string): string {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return ''
|
||||
const now = Date.now()
|
||||
const diff = now - date.getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return 'Gerade eben'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `vor ${minutes} Min`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `vor ${hours} Std`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `vor ${days} Tag${days > 1 ? 'en' : ''}`
|
||||
}
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectInfo
|
||||
onClick: () => void
|
||||
onDelete?: () => void
|
||||
onRestore?: () => void
|
||||
}
|
||||
|
||||
export function ProjectCard({
|
||||
project,
|
||||
onClick,
|
||||
onDelete,
|
||||
onRestore,
|
||||
}: ProjectCardProps) {
|
||||
const timeAgo = formatTimeAgo(project.updatedAt)
|
||||
const isArchived = project.status === 'archived'
|
||||
|
||||
return (
|
||||
<div className={`relative bg-white rounded-xl border-2 transition-all ${
|
||||
isArchived
|
||||
? 'border-gray-200 opacity-75'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
|
||||
}`}>
|
||||
{/* Action buttons */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 z-10">
|
||||
{isArchived && onRestore && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onRestore()
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="Wiederherstellen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="p-1.5 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Entfernen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card content */}
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="block w-full text-left p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3 pr-16">
|
||||
<h3 className={`font-semibold text-lg truncate pr-2 ${isArchived ? 'text-gray-500' : 'text-gray-900'}`}>
|
||||
{project.name}
|
||||
</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
|
||||
isArchived
|
||||
? 'bg-gray-100 text-gray-500'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{isArchived ? 'Archiviert' : 'Aktiv'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{project.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="font-mono">V{String(project.projectVersion).padStart(3, '0')}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
project.completionPercentage === 100 ? 'bg-green-500' : isArchived ? 'bg-gray-400' : 'bg-purple-600'
|
||||
}`}
|
||||
style={{ width: `${project.completionPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium text-gray-600">{project.completionPercentage}%</span>
|
||||
</div>
|
||||
{timeAgo && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>{timeAgo}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{project.customerType === 'new' ? 'Neukunde' : 'Bestandskunde'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,454 +3,10 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import type { ProjectInfo, CustomerType } from '@/lib/sdk/types'
|
||||
|
||||
// =============================================================================
|
||||
// HELPERS
|
||||
// =============================================================================
|
||||
|
||||
/** Map snake_case backend response to camelCase ProjectInfo */
|
||||
function normalizeProject(p: any): ProjectInfo {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
customerType: p.customerType || p.customer_type || 'new',
|
||||
status: p.status || 'active',
|
||||
projectVersion: p.projectVersion ?? p.project_version ?? 1,
|
||||
completionPercentage: p.completionPercentage ?? p.completion_percentage ?? 0,
|
||||
createdAt: p.createdAt || p.created_at || '',
|
||||
updatedAt: p.updatedAt || p.updated_at || '',
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(dateStr: string): string {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime())) return ''
|
||||
const now = Date.now()
|
||||
const diff = now - date.getTime()
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
if (seconds < 60) return 'Gerade eben'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `vor ${minutes} Min`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `vor ${hours} Std`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `vor ${days} Tag${days > 1 ? 'en' : ''}`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CREATE PROJECT DIALOG
|
||||
// =============================================================================
|
||||
|
||||
interface CreateProjectDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: (project: ProjectInfo) => void
|
||||
existingProjects: ProjectInfo[]
|
||||
}
|
||||
|
||||
function CreateProjectDialog({ open, onClose, onCreated, existingProjects }: CreateProjectDialogProps) {
|
||||
const { createProject } = useSDK()
|
||||
const [name, setName] = useState('')
|
||||
const [customerType, setCustomerType] = useState<CustomerType>('new')
|
||||
const [copyFromId, setCopyFromId] = useState<string>('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) {
|
||||
setError('Projektname ist erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const project = await createProject(
|
||||
name.trim(),
|
||||
customerType,
|
||||
copyFromId || undefined
|
||||
)
|
||||
onCreated(normalizeProject(project))
|
||||
setName('')
|
||||
setCopyFromId('')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Projekts')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">Neues Projekt erstellen</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Project Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Projektname *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="z.B. KI-Produkt X, SaaS API, Tochter GmbH..."
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Customer Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Projekttyp
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomerType('new')}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||||
customerType === 'new'
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900">Neukunde</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">Compliance von Grund auf</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCustomerType('existing')}
|
||||
className={`p-3 rounded-lg border-2 text-left transition-all ${
|
||||
customerType === 'existing'
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900">Bestandskunde</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">Bestehende Dokumente erweitern</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy from existing project */}
|
||||
{existingProjects.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Stammdaten kopieren von (optional)
|
||||
</label>
|
||||
<select
|
||||
value={copyFromId}
|
||||
onChange={e => setCopyFromId(e.target.value)}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none bg-white"
|
||||
>
|
||||
<option value="">— Keine Kopie (leeres Projekt) —</option>
|
||||
{existingProjects.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name} (V{String(p.projectVersion).padStart(3, '0')})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Firmenprofil wird kopiert und kann dann unabhaengig bearbeitet werden.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim()}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:bg-purple-300 rounded-lg transition-colors"
|
||||
>
|
||||
{isSubmitting ? 'Erstelle...' : 'Projekt erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROJECT ACTION DIALOG (Archive / Permanent Delete)
|
||||
// =============================================================================
|
||||
|
||||
type ActionStep = 'choose' | 'confirm-delete'
|
||||
|
||||
function ProjectActionDialog({
|
||||
project,
|
||||
onArchive,
|
||||
onPermanentDelete,
|
||||
onCancel,
|
||||
isProcessing,
|
||||
}: {
|
||||
project: ProjectInfo
|
||||
onArchive: () => void
|
||||
onPermanentDelete: () => void
|
||||
onCancel: () => void
|
||||
isProcessing: boolean
|
||||
}) {
|
||||
const [step, setStep] = useState<ActionStep>('choose')
|
||||
|
||||
if (step === 'confirm-delete') {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-red-700">Endgueltig loeschen</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-red-800 font-medium mb-2">
|
||||
Sind Sie sicher, dass Sie <strong>{project.name}</strong> unwiderruflich loeschen moechten?
|
||||
</p>
|
||||
<p className="text-sm text-red-700">
|
||||
Alle Projektdaten, SDK-States und Dokumente werden permanent geloescht. Diese Aktion kann nicht rueckgaengig gemacht werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setStep('choose')}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<button
|
||||
onClick={onPermanentDelete}
|
||||
disabled={isProcessing}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-red-700 hover:bg-red-800 disabled:bg-red-300 rounded-lg transition-colors"
|
||||
>
|
||||
{isProcessing ? 'Loesche...' : 'Endgueltig loeschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900">Projekt entfernen</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-6">
|
||||
Was moechten Sie mit dem Projekt <strong>{project.name}</strong> tun?
|
||||
</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
{/* Archive option */}
|
||||
<button
|
||||
onClick={onArchive}
|
||||
disabled={isProcessing}
|
||||
className="w-full flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all text-left disabled:opacity-50"
|
||||
>
|
||||
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Archivieren</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Projekt wird ausgeblendet, Daten bleiben erhalten. Kann jederzeit wiederhergestellt werden.
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Permanent delete option */}
|
||||
<button
|
||||
onClick={() => setStep('confirm-delete')}
|
||||
disabled={isProcessing}
|
||||
className="w-full flex items-start gap-4 p-4 rounded-xl border-2 border-gray-200 hover:border-red-300 hover:bg-red-50 transition-all text-left disabled:opacity-50"
|
||||
>
|
||||
<div className="w-8 h-8 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-red-700">Endgueltig loeschen</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
Alle Daten werden unwiderruflich geloescht. Diese Aktion kann nicht rueckgaengig gemacht werden.
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROJECT CARD
|
||||
// =============================================================================
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
onClick,
|
||||
onDelete,
|
||||
onRestore,
|
||||
}: {
|
||||
project: ProjectInfo
|
||||
onClick: () => void
|
||||
onDelete?: () => void
|
||||
onRestore?: () => void
|
||||
}) {
|
||||
const timeAgo = formatTimeAgo(project.updatedAt)
|
||||
const isArchived = project.status === 'archived'
|
||||
|
||||
return (
|
||||
<div className={`relative bg-white rounded-xl border-2 transition-all ${
|
||||
isArchived
|
||||
? 'border-gray-200 opacity-75'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
|
||||
}`}>
|
||||
{/* Action buttons */}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-1 z-10">
|
||||
{isArchived && onRestore && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onRestore()
|
||||
}}
|
||||
className="p-1.5 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="Wiederherstellen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onDelete()
|
||||
}}
|
||||
className="p-1.5 text-gray-300 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Entfernen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card content */}
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="block w-full text-left p-6"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3 pr-16">
|
||||
<h3 className={`font-semibold text-lg truncate pr-2 ${isArchived ? 'text-gray-500' : 'text-gray-900'}`}>
|
||||
{project.name}
|
||||
</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
|
||||
isArchived
|
||||
? 'bg-gray-100 text-gray-500'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{isArchived ? 'Archiviert' : 'Aktiv'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{project.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span className="font-mono">V{String(project.projectVersion).padStart(3, '0')}</span>
|
||||
<span className="text-gray-300">|</span>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
project.completionPercentage === 100 ? 'bg-green-500' : isArchived ? 'bg-gray-400' : 'bg-purple-600'
|
||||
}`}
|
||||
style={{ width: `${project.completionPercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium text-gray-600">{project.completionPercentage}%</span>
|
||||
</div>
|
||||
{timeAgo && (
|
||||
<>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span>{timeAgo}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{project.customerType === 'new' ? 'Neukunde' : 'Bestandskunde'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
import type { ProjectInfo } from '@/lib/sdk/types'
|
||||
import { CreateProjectDialog, normalizeProject } from './CreateProjectDialog'
|
||||
import { ProjectActionDialog } from './ProjectActionDialog'
|
||||
import { ProjectCard } from './ProjectCard'
|
||||
|
||||
export function ProjectSelector() {
|
||||
const router = useRouter()
|
||||
@@ -494,7 +50,6 @@ export function ProjectSelector() {
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
await archiveProject(actionTarget.id)
|
||||
// Move from active to archived
|
||||
setProjects(prev => prev.filter(p => p.id !== actionTarget.id))
|
||||
setArchivedProjects(prev => [...prev, { ...actionTarget, status: 'archived' as const }])
|
||||
setActionTarget(null)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AIModuleReviewTriggerType } from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
|
||||
export const TABS = [
|
||||
{ id: 1, label: 'System', icon: '🖥️' },
|
||||
{ id: 2, label: 'Daten', icon: '📊' },
|
||||
{ id: 3, label: 'Zweck & Art. 22', icon: '⚖️' },
|
||||
{ id: 4, label: 'KI-Kriterien', icon: '🔍' },
|
||||
{ id: 5, label: 'Risiken', icon: '⚠️' },
|
||||
{ id: 6, label: 'Maßnahmen', icon: '🛡️' },
|
||||
{ id: 7, label: 'Review', icon: '🔄' },
|
||||
]
|
||||
|
||||
export const REVIEW_TRIGGER_TYPES: { value: AIModuleReviewTriggerType; label: string; icon: string }[] = [
|
||||
{ value: 'model_update', label: 'Modell-Update', icon: '🔄' },
|
||||
{ value: 'data_drift', label: 'Datendrift', icon: '📉' },
|
||||
{ value: 'accuracy_drop', label: 'Genauigkeitsabfall', icon: '📊' },
|
||||
{ value: 'new_use_case', label: 'Neuer Anwendungsfall', icon: '🎯' },
|
||||
{ value: 'regulatory_change', label: 'Regulatorische Änderung', icon: '📜' },
|
||||
{ value: 'incident', label: 'Sicherheitsvorfall', icon: '🚨' },
|
||||
{ value: 'periodic', label: 'Regelmäßig (zeitbasiert)', icon: '📅' },
|
||||
]
|
||||
|
||||
export const LEGAL_BASES = [
|
||||
'Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)',
|
||||
'Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung)',
|
||||
'Art. 6 Abs. 1 lit. c DSGVO (Rechtliche Verpflichtung)',
|
||||
'Art. 6 Abs. 1 lit. f DSGVO (Berechtigtes Interesse)',
|
||||
'Art. 9 Abs. 2 lit. a DSGVO (Ausdrückliche Einwilligung)',
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
// Barrel re-export — implementation split into focused files
|
||||
export { Tab1System, Tab2Data } from './AIUseCaseTabsSystemData'
|
||||
export { Tab3Purpose, Tab4AIAct } from './AIUseCaseTabsPurposeAct'
|
||||
export { Tab5Risks, Tab6PrivacyByDesign, Tab7Review } from './AIUseCaseTabsRisksReview'
|
||||
@@ -3,10 +3,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
AIUseCaseModule,
|
||||
AIUseCaseType,
|
||||
AIActRiskClass,
|
||||
AI_USE_CASE_TYPES,
|
||||
AI_ACT_RISK_CLASSES,
|
||||
PRIVACY_BY_DESIGN_CATEGORIES,
|
||||
PrivacyByDesignCategory,
|
||||
PrivacyByDesignMeasure,
|
||||
@@ -14,8 +11,16 @@ import {
|
||||
AIModuleReviewTriggerType,
|
||||
checkArt22Applicability,
|
||||
} from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
import { Art22AssessmentPanel } from './Art22AssessmentPanel'
|
||||
import { AIRiskCriteriaChecklist } from './AIRiskCriteriaChecklist'
|
||||
import { TABS, REVIEW_TRIGGER_TYPES } from './AIUseCaseEditorConstants'
|
||||
import {
|
||||
Tab1System,
|
||||
Tab2Data,
|
||||
Tab3Purpose,
|
||||
Tab4AIAct,
|
||||
Tab5Risks,
|
||||
Tab6PrivacyByDesign,
|
||||
Tab7Review,
|
||||
} from './AIUseCaseEditorTabs'
|
||||
|
||||
interface AIUseCaseModuleEditorProps {
|
||||
module: AIUseCaseModule
|
||||
@@ -24,34 +29,6 @@ interface AIUseCaseModuleEditorProps {
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 1, label: 'System', icon: '🖥️' },
|
||||
{ id: 2, label: 'Daten', icon: '📊' },
|
||||
{ id: 3, label: 'Zweck & Art. 22', icon: '⚖️' },
|
||||
{ id: 4, label: 'KI-Kriterien', icon: '🔍' },
|
||||
{ id: 5, label: 'Risiken', icon: '⚠️' },
|
||||
{ id: 6, label: 'Maßnahmen', icon: '🛡️' },
|
||||
{ id: 7, label: 'Review', icon: '🔄' },
|
||||
]
|
||||
|
||||
const REVIEW_TRIGGER_TYPES: { value: AIModuleReviewTriggerType; label: string; icon: string }[] = [
|
||||
{ value: 'model_update', label: 'Modell-Update', icon: '🔄' },
|
||||
{ value: 'data_drift', label: 'Datendrift', icon: '📉' },
|
||||
{ value: 'accuracy_drop', label: 'Genauigkeitsabfall', icon: '📊' },
|
||||
{ value: 'new_use_case', label: 'Neuer Anwendungsfall', icon: '🎯' },
|
||||
{ value: 'regulatory_change', label: 'Regulatorische Änderung', icon: '📜' },
|
||||
{ value: 'incident', label: 'Sicherheitsvorfall', icon: '🚨' },
|
||||
{ value: 'periodic', label: 'Regelmäßig (zeitbasiert)', icon: '📅' },
|
||||
]
|
||||
|
||||
const LEGAL_BASES = [
|
||||
'Art. 6 Abs. 1 lit. a DSGVO (Einwilligung)',
|
||||
'Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung)',
|
||||
'Art. 6 Abs. 1 lit. c DSGVO (Rechtliche Verpflichtung)',
|
||||
'Art. 6 Abs. 1 lit. f DSGVO (Berechtigtes Interesse)',
|
||||
'Art. 9 Abs. 2 lit. a DSGVO (Ausdrückliche Einwilligung)',
|
||||
]
|
||||
|
||||
export function AIUseCaseModuleEditor({ module: initialModule, onSave, onCancel, isSaving }: AIUseCaseModuleEditorProps) {
|
||||
const [activeTab, setActiveTab] = useState(1)
|
||||
const [module, setModule] = useState<AIUseCaseModule>(initialModule)
|
||||
@@ -146,514 +123,26 @@ export function AIUseCaseModuleEditor({ module: initialModule, onSave, onCancel,
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Tab 1: Systembeschreibung */}
|
||||
{activeTab === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Anwendungsfalls *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.name}
|
||||
onChange={e => update({ name: e.target.value })}
|
||||
placeholder={`z.B. ${typeInfo.label} für Kundenservice`}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung *</label>
|
||||
<textarea
|
||||
value={module.model_description}
|
||||
onChange={e => update({ model_description: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie das KI-System: Funktionsweise, Input/Output, eingesetzte Algorithmen..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Modell-Typ</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.model_type || ''}
|
||||
onChange={e => update({ model_type: e.target.value })}
|
||||
placeholder="z.B. Random Forest, GPT-4, CNN"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anbieter / Provider</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.provider || ''}
|
||||
onChange={e => update({ provider: e.target.value })}
|
||||
placeholder="z.B. Anthropic, OpenAI, intern"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datenfluss-Beschreibung</label>
|
||||
<textarea
|
||||
value={module.data_flow_description || ''}
|
||||
onChange={e => update({ data_flow_description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Wie fließen Daten in das KI-System ein und aus? Gibt es Drittland-Transfers?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="third_country"
|
||||
checked={module.third_country_transfer}
|
||||
onChange={e => update({ third_country_transfer: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<label htmlFor="third_country" className="text-sm text-gray-700">
|
||||
Drittland-Transfer (außerhalb EU/EWR)
|
||||
</label>
|
||||
{module.third_country_transfer && (
|
||||
<input
|
||||
type="text"
|
||||
value={module.provider_country || ''}
|
||||
onChange={e => update({ provider_country: e.target.value })}
|
||||
placeholder="Land (z.B. USA)"
|
||||
className="ml-2 px-2 py-1 text-sm border border-orange-300 rounded focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2: Daten & Betroffene */}
|
||||
{activeTab === 1 && <Tab1System module={module} update={update} typeInfo={typeInfo} />}
|
||||
{activeTab === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* Input Data Categories */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Input-Datenkategorien *</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategory}
|
||||
onChange={e => setNewCategory(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToList('input_data_categories', newCategory, setNewCategory)}
|
||||
placeholder="Datenkategorie hinzufügen..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addToList('input_data_categories', newCategory, setNewCategory)}
|
||||
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(module.input_data_categories || []).map((cat, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
{cat}
|
||||
<button onClick={() => removeFromList('input_data_categories', i)} className="hover:text-purple-900">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Data Categories */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Output-Datenkategorien</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newOutputCategory}
|
||||
onChange={e => setNewOutputCategory(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
|
||||
placeholder="Output-Kategorie (z.B. Bewertung, Empfehlung)..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
|
||||
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(module.output_data_categories || []).map((cat, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
|
||||
{cat}
|
||||
<button onClick={() => removeFromList('output_data_categories', i)} className="hover:text-blue-900">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Special Categories */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="special_cats"
|
||||
checked={module.involves_special_categories}
|
||||
onChange={e => update({ involves_special_categories: e.target.checked })}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<label htmlFor="special_cats" className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Besondere Kategorien (Art. 9 DSGVO)</div>
|
||||
<p className="text-xs text-gray-500">Gesundheit, Biometrie, Religion, politische Meinung etc.</p>
|
||||
{module.involves_special_categories && (
|
||||
<textarea
|
||||
value={module.special_categories_justification || ''}
|
||||
onChange={e => update({ special_categories_justification: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Begründung nach Art. 9 Abs. 2 DSGVO..."
|
||||
className="mt-2 w-full px-3 py-2 text-xs border border-orange-300 rounded focus:ring-2 focus:ring-orange-400 resize-none"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Data Subjects */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betroffenengruppen *</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubject}
|
||||
onChange={e => setNewSubject(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToList('data_subjects', newSubject, setNewSubject)}
|
||||
placeholder="z.B. Kunden, Mitarbeiter, Nutzer..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addToList('data_subjects', newSubject, setNewSubject)}
|
||||
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(module.data_subjects || []).map((s, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
|
||||
{s}
|
||||
<button onClick={() => removeFromList('data_subjects', i)} className="hover:text-green-900">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschätztes Volumen</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.estimated_volume || ''}
|
||||
onChange={e => update({ estimated_volume: e.target.value })}
|
||||
placeholder="z.B. >10.000 Personen/Monat"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungsdauer (Monate)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={module.data_retention_months || ''}
|
||||
onChange={e => update({ data_retention_months: parseInt(e.target.value) || undefined })}
|
||||
min={1}
|
||||
placeholder="z.B. 24"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 3: Zweck & Art. 22 */}
|
||||
{activeTab === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck *</label>
|
||||
<textarea
|
||||
value={module.processing_purpose}
|
||||
onChange={e => update({ processing_purpose: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Welchem Zweck dient dieses KI-System? Was soll erreicht werden?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsgrundlage *</label>
|
||||
<select
|
||||
value={module.legal_basis}
|
||||
onChange={e => update({ legal_basis: e.target.value })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{LEGAL_BASES.map(lb => (
|
||||
<option key={lb} value={lb}>{lb}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{module.legal_basis && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Details zur Rechtsgrundlage</label>
|
||||
<textarea
|
||||
value={module.legal_basis_details || ''}
|
||||
onChange={e => update({ legal_basis_details: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Ergänzende Erläuterung zur Rechtsgrundlage..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Art. 22 Panel */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Art. 22 DSGVO – Automatisierte Einzelentscheidung
|
||||
{art22Required && (
|
||||
<span className="ml-2 text-xs px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
|
||||
Wahrscheinlich anwendbar
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<Art22AssessmentPanel
|
||||
assessment={module.art22_assessment}
|
||||
onChange={a22 => update({ art22_assessment: a22 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 4: KI-Kriterien & AI Act */}
|
||||
{activeTab === 4 && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">WP248-Risikokriterien</h4>
|
||||
<AIRiskCriteriaChecklist
|
||||
criteria={module.risk_criteria}
|
||||
onChange={criteria => update({ risk_criteria: criteria })}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">EU AI Act – Risikoklasse</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(Object.entries(AI_ACT_RISK_CLASSES) as [AIActRiskClass, typeof AI_ACT_RISK_CLASSES[AIActRiskClass]][]).map(([cls, info]) => (
|
||||
<button
|
||||
key={cls}
|
||||
onClick={() => update({ ai_act_risk_class: cls })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
module.ai_act_risk_class === cls
|
||||
? cls === 'unacceptable' ? 'border-red-500 bg-red-50'
|
||||
: cls === 'high_risk' ? 'border-orange-500 bg-orange-50'
|
||||
: cls === 'limited' ? 'border-yellow-500 bg-yellow-50'
|
||||
: 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900">{info.labelDE}</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{info.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{module.ai_act_risk_class && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Begründung der Klassifizierung</label>
|
||||
<textarea
|
||||
value={module.ai_act_justification || ''}
|
||||
onChange={e => update({ ai_act_justification: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Warum wurde diese Risikoklasse gewählt?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{module.ai_act_risk_class && AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">Anforderungen dieser Klasse:</div>
|
||||
<ul className="space-y-1">
|
||||
{AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.map((req, i) => (
|
||||
<li key={i} className="text-xs text-gray-600 flex items-start gap-1.5">
|
||||
<span className="text-purple-500 flex-shrink-0">•</span>
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 5: Risikoanalyse */}
|
||||
{activeTab === 5 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Spezifische Risiken für diesen KI-Anwendungsfall. Typische Risiken basierend auf dem gewählten Typ:
|
||||
</p>
|
||||
{typeInfo.typical_risks.length > 0 && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-xs font-medium text-yellow-800 mb-1">Typische Risiken für {typeInfo.label}:</div>
|
||||
<ul className="space-y-0.5">
|
||||
{typeInfo.typical_risks.map((r, i) => (
|
||||
<li key={i} className="text-xs text-yellow-700 flex items-center gap-1.5">
|
||||
<span>⚠️</span> {r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{(module.risks || []).map((risk, idx) => (
|
||||
<div key={idx} className="p-3 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-900">{risk.description}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded">W: {risk.likelihood}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-600 rounded">S: {risk.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => update({ risks: module.risks.filter((_, i) => i !== idx) })}
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(module.risks || []).length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-4">Noch keine Risiken dokumentiert</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Add Risk */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const desc = prompt('Risiko-Beschreibung:')
|
||||
if (desc) {
|
||||
update({
|
||||
risks: [...(module.risks || []), {
|
||||
risk_id: crypto.randomUUID(),
|
||||
description: desc,
|
||||
likelihood: 'medium',
|
||||
impact: 'medium',
|
||||
mitigation_ids: [],
|
||||
}]
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-purple-400 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
+ Risiko hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 6: Maßnahmen & Privacy by Design */}
|
||||
{activeTab === 6 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Privacy by Design Maßnahmen</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.entries(PRIVACY_BY_DESIGN_CATEGORIES) as [PrivacyByDesignCategory, typeof PRIVACY_BY_DESIGN_CATEGORIES[PrivacyByDesignCategory]][]).map(([cat, info]) => {
|
||||
const measure = module.privacy_by_design_measures?.find(m => m.category === cat)
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => togglePbdMeasure(cat)}
|
||||
className={`flex items-start gap-2 p-3 rounded-lg border text-left transition-all ${
|
||||
measure?.implemented
|
||||
? 'border-green-400 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg flex-shrink-0">{info.icon}</span>
|
||||
<div>
|
||||
<div className={`text-xs font-medium ${measure?.implemented ? 'text-green-800' : 'text-gray-700'}`}>
|
||||
{info.label}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{info.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 7: Review-Trigger */}
|
||||
{activeTab === 7 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Wählen Sie die Ereignisse, die eine erneute Bewertung dieses KI-Anwendungsfalls auslösen sollen.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{REVIEW_TRIGGER_TYPES.map(rt => {
|
||||
const active = module.review_triggers?.some(t => t.type === rt.value)
|
||||
const trigger = module.review_triggers?.find(t => t.type === rt.value)
|
||||
return (
|
||||
<div key={rt.value} className={`rounded-lg border p-3 transition-all ${active ? 'border-purple-300 bg-purple-50' : 'border-gray-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active || false}
|
||||
onChange={() => toggleReviewTrigger(rt.value)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<span className="text-base">{rt.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{rt.label}</span>
|
||||
</div>
|
||||
{active && (
|
||||
<div className="mt-2 ml-7 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={trigger?.threshold || ''}
|
||||
onChange={e => {
|
||||
const updated = (module.review_triggers || []).map(t =>
|
||||
t.type === rt.value ? { ...t, threshold: e.target.value } : t
|
||||
)
|
||||
update({ review_triggers: updated })
|
||||
}}
|
||||
placeholder="Schwellwert (z.B. Genauigkeit < 80%)"
|
||||
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={trigger?.monitoring_interval || ''}
|
||||
onChange={e => {
|
||||
const updated = (module.review_triggers || []).map(t =>
|
||||
t.type === rt.value ? { ...t, monitoring_interval: e.target.value } : t
|
||||
)
|
||||
update({ review_triggers: updated })
|
||||
}}
|
||||
placeholder="Monitoring-Intervall (z.B. wöchentlich)"
|
||||
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Monitoring-Beschreibung</label>
|
||||
<textarea
|
||||
value={module.monitoring_description || ''}
|
||||
onChange={e => update({ monitoring_description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Wie wird das KI-System kontinuierlich überwacht? Welche Metriken werden erfasst?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nächstes Review-Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={module.next_review_date || ''}
|
||||
onChange={e => update({ next_review_date: e.target.value })}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Tab2Data
|
||||
module={module}
|
||||
update={update}
|
||||
newCategory={newCategory}
|
||||
setNewCategory={setNewCategory}
|
||||
newOutputCategory={newOutputCategory}
|
||||
setNewOutputCategory={setNewOutputCategory}
|
||||
newSubject={newSubject}
|
||||
setNewSubject={setNewSubject}
|
||||
addToList={addToList}
|
||||
removeFromList={removeFromList}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 3 && <Tab3Purpose module={module} update={update} art22Required={art22Required} />}
|
||||
{activeTab === 4 && <Tab4AIAct module={module} update={update} />}
|
||||
{activeTab === 5 && <Tab5Risks module={module} update={update} typeInfo={typeInfo} />}
|
||||
{activeTab === 6 && <Tab6PrivacyByDesign module={module} update={update} togglePbdMeasure={togglePbdMeasure} />}
|
||||
{activeTab === 7 && <Tab7Review module={module} update={update} toggleReviewTrigger={toggleReviewTrigger} />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
149
admin-compliance/components/sdk/dsfa/AIUseCaseTabsPurposeAct.tsx
Normal file
149
admin-compliance/components/sdk/dsfa/AIUseCaseTabsPurposeAct.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
AIUseCaseModule,
|
||||
AIActRiskClass,
|
||||
AI_ACT_RISK_CLASSES,
|
||||
} from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
import { Art22AssessmentPanel } from './Art22AssessmentPanel'
|
||||
import { AIRiskCriteriaChecklist } from './AIRiskCriteriaChecklist'
|
||||
import { LEGAL_BASES } from './AIUseCaseEditorConstants'
|
||||
|
||||
type UpdateFn = (updates: Partial<AIUseCaseModule>) => void
|
||||
|
||||
// =============================================================================
|
||||
// TAB 3: Zweck & Art. 22
|
||||
// =============================================================================
|
||||
|
||||
interface Tab3PurposeProps {
|
||||
module: AIUseCaseModule
|
||||
update: UpdateFn
|
||||
art22Required: boolean
|
||||
}
|
||||
|
||||
export function Tab3Purpose({ module, update, art22Required }: Tab3PurposeProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck *</label>
|
||||
<textarea
|
||||
value={module.processing_purpose}
|
||||
onChange={e => update({ processing_purpose: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Welchem Zweck dient dieses KI-System? Was soll erreicht werden?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsgrundlage *</label>
|
||||
<select
|
||||
value={module.legal_basis}
|
||||
onChange={e => update({ legal_basis: e.target.value })}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{LEGAL_BASES.map(lb => (
|
||||
<option key={lb} value={lb}>{lb}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{module.legal_basis && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Details zur Rechtsgrundlage</label>
|
||||
<textarea
|
||||
value={module.legal_basis_details || ''}
|
||||
onChange={e => update({ legal_basis_details: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Ergänzende Erläuterung zur Rechtsgrundlage..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Art. 22 DSGVO – Automatisierte Einzelentscheidung
|
||||
{art22Required && (
|
||||
<span className="ml-2 text-xs px-1.5 py-0.5 bg-red-100 text-red-700 rounded">
|
||||
Wahrscheinlich anwendbar
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<Art22AssessmentPanel
|
||||
assessment={module.art22_assessment}
|
||||
onChange={a22 => update({ art22_assessment: a22 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB 4: KI-Kriterien & AI Act
|
||||
// =============================================================================
|
||||
|
||||
interface Tab4AIActProps {
|
||||
module: AIUseCaseModule
|
||||
update: UpdateFn
|
||||
}
|
||||
|
||||
export function Tab4AIAct({ module, update }: Tab4AIActProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">WP248-Risikokriterien</h4>
|
||||
<AIRiskCriteriaChecklist
|
||||
criteria={module.risk_criteria}
|
||||
onChange={criteria => update({ risk_criteria: criteria })}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">EU AI Act – Risikoklasse</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(Object.entries(AI_ACT_RISK_CLASSES) as [AIActRiskClass, typeof AI_ACT_RISK_CLASSES[AIActRiskClass]][]).map(([cls, info]) => (
|
||||
<button
|
||||
key={cls}
|
||||
onClick={() => update({ ai_act_risk_class: cls })}
|
||||
className={`p-3 rounded-xl border-2 text-left transition-all ${
|
||||
module.ai_act_risk_class === cls
|
||||
? cls === 'unacceptable' ? 'border-red-500 bg-red-50'
|
||||
: cls === 'high_risk' ? 'border-orange-500 bg-orange-50'
|
||||
: cls === 'limited' ? 'border-yellow-500 bg-yellow-50'
|
||||
: 'border-green-500 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-900">{info.labelDE}</div>
|
||||
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{info.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{module.ai_act_risk_class && (
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Begründung der Klassifizierung</label>
|
||||
<textarea
|
||||
value={module.ai_act_justification || ''}
|
||||
onChange={e => update({ ai_act_justification: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Warum wurde diese Risikoklasse gewählt?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{module.ai_act_risk_class && AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-xs font-medium text-gray-700 mb-2">Anforderungen dieser Klasse:</div>
|
||||
<ul className="space-y-1">
|
||||
{AI_ACT_RISK_CLASSES[module.ai_act_risk_class].requirements.map((req, i) => (
|
||||
<li key={i} className="text-xs text-gray-600 flex items-start gap-1.5">
|
||||
<span className="text-purple-500 flex-shrink-0">•</span>
|
||||
{req}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
AIUseCaseModule,
|
||||
AI_USE_CASE_TYPES,
|
||||
PRIVACY_BY_DESIGN_CATEGORIES,
|
||||
PrivacyByDesignCategory,
|
||||
AIModuleReviewTriggerType,
|
||||
} from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
import { REVIEW_TRIGGER_TYPES } from './AIUseCaseEditorConstants'
|
||||
|
||||
type UpdateFn = (updates: Partial<AIUseCaseModule>) => void
|
||||
|
||||
// =============================================================================
|
||||
// TAB 5: Risikoanalyse
|
||||
// =============================================================================
|
||||
|
||||
interface Tab5RisksProps {
|
||||
module: AIUseCaseModule
|
||||
update: UpdateFn
|
||||
typeInfo: typeof AI_USE_CASE_TYPES[keyof typeof AI_USE_CASE_TYPES]
|
||||
}
|
||||
|
||||
export function Tab5Risks({ module, update, typeInfo }: Tab5RisksProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Spezifische Risiken für diesen KI-Anwendungsfall. Typische Risiken basierend auf dem gewählten Typ:
|
||||
</p>
|
||||
{typeInfo.typical_risks.length > 0 && (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-xs font-medium text-yellow-800 mb-1">Typische Risiken für {typeInfo.label}:</div>
|
||||
<ul className="space-y-0.5">
|
||||
{typeInfo.typical_risks.map((r, i) => (
|
||||
<li key={i} className="text-xs text-yellow-700 flex items-center gap-1.5">
|
||||
<span>⚠️</span> {r}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{(module.risks || []).map((risk, idx) => (
|
||||
<div key={idx} className="p-3 border border-gray-200 rounded-lg bg-gray-50">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-900">{risk.description}</p>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded">W: {risk.likelihood}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-600 rounded">S: {risk.impact}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => update({ risks: module.risks.filter((_, i) => i !== idx) })}
|
||||
className="text-gray-400 hover:text-red-500 ml-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(module.risks || []).length === 0 && (
|
||||
<p className="text-sm text-gray-400 text-center py-4">Noch keine Risiken dokumentiert</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const desc = prompt('Risiko-Beschreibung:')
|
||||
if (desc) {
|
||||
update({
|
||||
risks: [...(module.risks || []), {
|
||||
risk_id: crypto.randomUUID(),
|
||||
description: desc,
|
||||
likelihood: 'medium',
|
||||
impact: 'medium',
|
||||
mitigation_ids: [],
|
||||
}]
|
||||
})
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 border-2 border-dashed border-gray-300 rounded-lg text-sm text-gray-500 hover:border-purple-400 hover:text-purple-600 transition-colors"
|
||||
>
|
||||
+ Risiko hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB 6: Maßnahmen & Privacy by Design
|
||||
// =============================================================================
|
||||
|
||||
interface Tab6PrivacyByDesignProps {
|
||||
module: AIUseCaseModule
|
||||
update: UpdateFn
|
||||
togglePbdMeasure: (category: PrivacyByDesignCategory) => void
|
||||
}
|
||||
|
||||
export function Tab6PrivacyByDesign({ module, togglePbdMeasure }: Tab6PrivacyByDesignProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Privacy by Design Maßnahmen</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.entries(PRIVACY_BY_DESIGN_CATEGORIES) as [PrivacyByDesignCategory, typeof PRIVACY_BY_DESIGN_CATEGORIES[PrivacyByDesignCategory]][]).map(([cat, info]) => {
|
||||
const measure = module.privacy_by_design_measures?.find(m => m.category === cat)
|
||||
return (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => togglePbdMeasure(cat)}
|
||||
className={`flex items-start gap-2 p-3 rounded-lg border text-left transition-all ${
|
||||
measure?.implemented
|
||||
? 'border-green-400 bg-green-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg flex-shrink-0">{info.icon}</span>
|
||||
<div>
|
||||
<div className={`text-xs font-medium ${measure?.implemented ? 'text-green-800' : 'text-gray-700'}`}>
|
||||
{info.label}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-500 mt-0.5">{info.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB 7: Review-Trigger
|
||||
// =============================================================================
|
||||
|
||||
interface Tab7ReviewProps {
|
||||
module: AIUseCaseModule
|
||||
update: UpdateFn
|
||||
toggleReviewTrigger: (type: AIModuleReviewTriggerType) => void
|
||||
}
|
||||
|
||||
export function Tab7Review({ module, update, toggleReviewTrigger }: Tab7ReviewProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Wählen Sie die Ereignisse, die eine erneute Bewertung dieses KI-Anwendungsfalls auslösen sollen.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{REVIEW_TRIGGER_TYPES.map(rt => {
|
||||
const active = module.review_triggers?.some(t => t.type === rt.value)
|
||||
const trigger = module.review_triggers?.find(t => t.type === rt.value)
|
||||
return (
|
||||
<div key={rt.value} className={`rounded-lg border p-3 transition-all ${active ? 'border-purple-300 bg-purple-50' : 'border-gray-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active || false}
|
||||
onChange={() => toggleReviewTrigger(rt.value)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<span className="text-base">{rt.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{rt.label}</span>
|
||||
</div>
|
||||
{active && (
|
||||
<div className="mt-2 ml-7 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={trigger?.threshold || ''}
|
||||
onChange={e => {
|
||||
const updated = (module.review_triggers || []).map(t =>
|
||||
t.type === rt.value ? { ...t, threshold: e.target.value } : t
|
||||
)
|
||||
update({ review_triggers: updated })
|
||||
}}
|
||||
placeholder="Schwellwert (z.B. Genauigkeit < 80%)"
|
||||
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={trigger?.monitoring_interval || ''}
|
||||
onChange={e => {
|
||||
const updated = (module.review_triggers || []).map(t =>
|
||||
t.type === rt.value ? { ...t, monitoring_interval: e.target.value } : t
|
||||
)
|
||||
update({ review_triggers: updated })
|
||||
}}
|
||||
placeholder="Monitoring-Intervall (z.B. wöchentlich)"
|
||||
className="w-full px-2 py-1 text-xs border border-purple-200 rounded focus:ring-2 focus:ring-purple-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Monitoring-Beschreibung</label>
|
||||
<textarea
|
||||
value={module.monitoring_description || ''}
|
||||
onChange={e => update({ monitoring_description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Wie wird das KI-System kontinuierlich überwacht? Welche Metriken werden erfasst?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nächstes Review-Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={module.next_review_date || ''}
|
||||
onChange={e => update({ next_review_date: e.target.value })}
|
||||
className="px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
261
admin-compliance/components/sdk/dsfa/AIUseCaseTabsSystemData.tsx
Normal file
261
admin-compliance/components/sdk/dsfa/AIUseCaseTabsSystemData.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
AIUseCaseModule,
|
||||
AI_USE_CASE_TYPES,
|
||||
} from '@/lib/sdk/dsfa/ai-use-case-types'
|
||||
|
||||
type UpdateFn = (updates: Partial<AIUseCaseModule>) => void
|
||||
type AddToListFn = (field: keyof AIUseCaseModule, value: string, setter: (v: string) => void) => void
|
||||
type RemoveFromListFn = (field: keyof AIUseCaseModule, idx: number) => void
|
||||
|
||||
// =============================================================================
|
||||
// TAB 1: System
|
||||
// =============================================================================
|
||||
|
||||
interface Tab1SystemProps {
|
||||
module: AIUseCaseModule
|
||||
update: UpdateFn
|
||||
typeInfo: typeof AI_USE_CASE_TYPES[keyof typeof AI_USE_CASE_TYPES]
|
||||
}
|
||||
|
||||
export function Tab1System({ module, update, typeInfo }: Tab1SystemProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Anwendungsfalls *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.name}
|
||||
onChange={e => update({ name: e.target.value })}
|
||||
placeholder={`z.B. ${typeInfo.label} für Kundenservice`}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung *</label>
|
||||
<textarea
|
||||
value={module.model_description}
|
||||
onChange={e => update({ model_description: e.target.value })}
|
||||
rows={4}
|
||||
placeholder="Beschreiben Sie das KI-System: Funktionsweise, Input/Output, eingesetzte Algorithmen..."
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Modell-Typ</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.model_type || ''}
|
||||
onChange={e => update({ model_type: e.target.value })}
|
||||
placeholder="z.B. Random Forest, GPT-4, CNN"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anbieter / Provider</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.provider || ''}
|
||||
onChange={e => update({ provider: e.target.value })}
|
||||
placeholder="z.B. Anthropic, OpenAI, intern"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datenfluss-Beschreibung</label>
|
||||
<textarea
|
||||
value={module.data_flow_description || ''}
|
||||
onChange={e => update({ data_flow_description: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="Wie fließen Daten in das KI-System ein und aus? Gibt es Drittland-Transfers?"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="third_country"
|
||||
checked={module.third_country_transfer}
|
||||
onChange={e => update({ third_country_transfer: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<label htmlFor="third_country" className="text-sm text-gray-700">
|
||||
Drittland-Transfer (außerhalb EU/EWR)
|
||||
</label>
|
||||
{module.third_country_transfer && (
|
||||
<input
|
||||
type="text"
|
||||
value={module.provider_country || ''}
|
||||
onChange={e => update({ provider_country: e.target.value })}
|
||||
placeholder="Land (z.B. USA)"
|
||||
className="ml-2 px-2 py-1 text-sm border border-orange-300 rounded focus:ring-2 focus:ring-orange-500"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TAB 2: Daten & Betroffene
|
||||
// =============================================================================
|
||||
|
||||
interface Tab2DataProps {
|
||||
module: AIUseCaseModule
|
||||
update: UpdateFn
|
||||
newCategory: string
|
||||
setNewCategory: (v: string) => void
|
||||
newOutputCategory: string
|
||||
setNewOutputCategory: (v: string) => void
|
||||
newSubject: string
|
||||
setNewSubject: (v: string) => void
|
||||
addToList: AddToListFn
|
||||
removeFromList: RemoveFromListFn
|
||||
}
|
||||
|
||||
export function Tab2Data({
|
||||
module, update,
|
||||
newCategory, setNewCategory,
|
||||
newOutputCategory, setNewOutputCategory,
|
||||
newSubject, setNewSubject,
|
||||
addToList, removeFromList,
|
||||
}: Tab2DataProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Input-Datenkategorien *</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newCategory}
|
||||
onChange={e => setNewCategory(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToList('input_data_categories', newCategory, setNewCategory)}
|
||||
placeholder="Datenkategorie hinzufügen..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addToList('input_data_categories', newCategory, setNewCategory)}
|
||||
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(module.input_data_categories || []).map((cat, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
{cat}
|
||||
<button onClick={() => removeFromList('input_data_categories', i)} className="hover:text-purple-900">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Output-Datenkategorien</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newOutputCategory}
|
||||
onChange={e => setNewOutputCategory(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
|
||||
placeholder="Output-Kategorie (z.B. Bewertung, Empfehlung)..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addToList('output_data_categories', newOutputCategory, setNewOutputCategory)}
|
||||
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(module.output_data_categories || []).map((cat, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">
|
||||
{cat}
|
||||
<button onClick={() => removeFromList('output_data_categories', i)} className="hover:text-blue-900">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg border border-gray-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="special_cats"
|
||||
checked={module.involves_special_categories}
|
||||
onChange={e => update({ involves_special_categories: e.target.checked })}
|
||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600"
|
||||
/>
|
||||
<label htmlFor="special_cats" className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">Besondere Kategorien (Art. 9 DSGVO)</div>
|
||||
<p className="text-xs text-gray-500">Gesundheit, Biometrie, Religion, politische Meinung etc.</p>
|
||||
{module.involves_special_categories && (
|
||||
<textarea
|
||||
value={module.special_categories_justification || ''}
|
||||
onChange={e => update({ special_categories_justification: e.target.value })}
|
||||
rows={2}
|
||||
placeholder="Begründung nach Art. 9 Abs. 2 DSGVO..."
|
||||
className="mt-2 w-full px-3 py-2 text-xs border border-orange-300 rounded focus:ring-2 focus:ring-orange-400 resize-none"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betroffenengruppen *</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubject}
|
||||
onChange={e => setNewSubject(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToList('data_subjects', newSubject, setNewSubject)}
|
||||
placeholder="z.B. Kunden, Mitarbeiter, Nutzer..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
onClick={() => addToList('data_subjects', newSubject, setNewSubject)}
|
||||
className="px-3 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(module.data_subjects || []).map((s, i) => (
|
||||
<span key={i} className="flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded text-xs">
|
||||
{s}
|
||||
<button onClick={() => removeFromList('data_subjects', i)} className="hover:text-green-900">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geschätztes Volumen</label>
|
||||
<input
|
||||
type="text"
|
||||
value={module.estimated_volume || ''}
|
||||
onChange={e => update({ estimated_volume: e.target.value })}
|
||||
placeholder="z.B. >10.000 Personen/Monat"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Aufbewahrungsdauer (Monate)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={module.data_retention_months || ''}
|
||||
onChange={e => update({ data_retention_months: parseInt(e.target.value) || undefined })}
|
||||
min={1}
|
||||
placeholder="z.B. 24"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,36 +9,7 @@
|
||||
*/
|
||||
|
||||
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 { Search } from 'lucide-react'
|
||||
import {
|
||||
DataPoint,
|
||||
DataPointCategory,
|
||||
@@ -48,12 +19,10 @@ import {
|
||||
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'
|
||||
import { SpecialCategoryWarning } from './DataPointCatalogHelpers'
|
||||
import { DataPointCategoryGroup } from './DataPointCategoryGroup'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -71,149 +40,29 @@ interface DataPointCatalogProps {
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HELPER COMPONENTS
|
||||
// CATEGORY ORDER
|
||||
// =============================================================================
|
||||
|
||||
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>
|
||||
)
|
||||
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
|
||||
@@ -229,29 +78,6 @@ export function DataPointCatalog({
|
||||
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)
|
||||
@@ -261,34 +87,23 @@ export function DataPointCatalog({
|
||||
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) {
|
||||
@@ -301,7 +116,6 @@ export function DataPointCatalog({
|
||||
return grouped
|
||||
}, [filteredDataPoints])
|
||||
|
||||
// Zaehle ausgewaehlte spezielle Kategorien fuer Warnungen
|
||||
const selectedSpecialCategories = useMemo(() => {
|
||||
const special = new Set<DataPointCategory>()
|
||||
for (const id of selectedIds) {
|
||||
@@ -321,7 +135,6 @@ export function DataPointCatalog({
|
||||
return special
|
||||
}, [selectedIds, dataPoints])
|
||||
|
||||
// Toggle category expansion
|
||||
const toggleCategory = (category: DataPointCategory) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev)
|
||||
@@ -334,7 +147,6 @@ export function DataPointCatalog({
|
||||
})
|
||||
}
|
||||
|
||||
// Stats
|
||||
const totalSelected = selectedIds.length
|
||||
const totalDataPoints = dataPoints.length
|
||||
|
||||
@@ -367,7 +179,7 @@ export function DataPointCatalog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Art. 9 DSGVO / BDSG § 26 / AI Act Warnungen */}
|
||||
{/* Art. 9 / BDSG § 26 / AI Act Warnungen */}
|
||||
{selectedSpecialCategories.size > 0 && (
|
||||
<div className="space-y-3">
|
||||
{selectedSpecialCategories.has('HEALTH_DATA') && !dismissedWarnings.has('HEALTH_DATA') && (
|
||||
@@ -397,7 +209,6 @@ export function DataPointCatalog({
|
||||
{/* 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
|
||||
@@ -408,8 +219,6 @@ export function DataPointCatalog({
|
||||
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')}
|
||||
@@ -422,8 +231,6 @@ export function DataPointCatalog({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Risk Filter */}
|
||||
<select
|
||||
value={filterRisk}
|
||||
onChange={(e) => setFilterRisk(e.target.value as RiskLevel | 'ALL')}
|
||||
@@ -434,8 +241,6 @@ export function DataPointCatalog({
|
||||
<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')}
|
||||
@@ -455,190 +260,18 @@ export function DataPointCatalog({
|
||||
<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
|
||||
<DataPointCategoryGroup
|
||||
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>
|
||||
category={category}
|
||||
categoryDataPoints={categoryDataPoints}
|
||||
selectedIds={selectedIds}
|
||||
isExpanded={expandedCategories.has(category)}
|
||||
readOnly={readOnly}
|
||||
language={language}
|
||||
onToggleCategory={toggleCategory}
|
||||
onToggleDataPoint={onToggle}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import {
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Key,
|
||||
Megaphone,
|
||||
MessageSquare,
|
||||
CreditCard,
|
||||
Bot,
|
||||
User,
|
||||
Mail,
|
||||
Activity,
|
||||
MapPin,
|
||||
Smartphone,
|
||||
BarChart3,
|
||||
Share2,
|
||||
Heart,
|
||||
Briefcase,
|
||||
FileText,
|
||||
FileCode,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DataPointCategory,
|
||||
RiskLevel,
|
||||
LegalBasis,
|
||||
SupportedLanguage,
|
||||
RISK_LEVEL_STYLING,
|
||||
LEGAL_BASIS_INFO,
|
||||
ARTICLE_9_WARNING,
|
||||
EMPLOYEE_DATA_WARNING,
|
||||
AI_DATA_WARNING,
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
|
||||
// =============================================================================
|
||||
// CategoryIcon
|
||||
// =============================================================================
|
||||
|
||||
export const CategoryIcon: React.FC<{ category: DataPointCategory; className?: string }> = ({
|
||||
category,
|
||||
className = 'w-5 h-5',
|
||||
}) => {
|
||||
const icons: Record<DataPointCategory, React.ReactNode> = {
|
||||
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} />}</>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RiskBadge
|
||||
// =============================================================================
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LegalBasisBadge
|
||||
// =============================================================================
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SpecialCategoryWarning
|
||||
// =============================================================================
|
||||
|
||||
export const SpecialCategoryWarning: React.FC<{
|
||||
category: DataPointCategory
|
||||
language: SupportedLanguage
|
||||
onClose?: () => void
|
||||
}> = ({ category, language, onClose }) => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Article9Badge
|
||||
// =============================================================================
|
||||
|
||||
export 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>
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
DataPoint,
|
||||
DataPointCategory,
|
||||
SupportedLanguage,
|
||||
CATEGORY_METADATA,
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
import { CategoryIcon } from './DataPointCatalogHelpers'
|
||||
import { DataPointRow } from './DataPointRow'
|
||||
|
||||
interface DataPointCategoryGroupProps {
|
||||
category: DataPointCategory
|
||||
categoryDataPoints: DataPoint[]
|
||||
selectedIds: string[]
|
||||
isExpanded: boolean
|
||||
readOnly: boolean
|
||||
language: SupportedLanguage
|
||||
onToggleCategory: (category: DataPointCategory) => void
|
||||
onToggleDataPoint: (id: string) => void
|
||||
}
|
||||
|
||||
export function DataPointCategoryGroup({
|
||||
category,
|
||||
categoryDataPoints,
|
||||
selectedIds,
|
||||
isExpanded,
|
||||
readOnly,
|
||||
language,
|
||||
onToggleCategory,
|
||||
onToggleDataPoint,
|
||||
}: DataPointCategoryGroupProps) {
|
||||
const meta = CATEGORY_METADATA[category]
|
||||
const selectedInCategory = categoryDataPoints.filter((dp) =>
|
||||
selectedIds.includes(dp.id)
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 rounded-xl overflow-hidden bg-white">
|
||||
{/* Category Header */}
|
||||
<button
|
||||
onClick={() => onToggleCategory(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) => (
|
||||
<DataPointRow
|
||||
key={dp.id}
|
||||
dp={dp}
|
||||
isSelected={selectedIds.includes(dp.id)}
|
||||
readOnly={readOnly}
|
||||
language={language}
|
||||
onToggle={onToggleDataPoint}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
108
admin-compliance/components/sdk/einwilligungen/DataPointRow.tsx
Normal file
108
admin-compliance/components/sdk/einwilligungen/DataPointRow.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import { CheckCircle, Circle, AlertTriangle } from 'lucide-react'
|
||||
import {
|
||||
DataPoint,
|
||||
SupportedLanguage,
|
||||
RETENTION_PERIOD_INFO,
|
||||
} from '@/lib/sdk/einwilligungen/types'
|
||||
import { RiskBadge, LegalBasisBadge, Article9Badge } from './DataPointCatalogHelpers'
|
||||
|
||||
interface DataPointRowProps {
|
||||
dp: DataPoint
|
||||
isSelected: boolean
|
||||
readOnly: boolean
|
||||
language: SupportedLanguage
|
||||
onToggle: (id: string) => void
|
||||
}
|
||||
|
||||
export function DataPointRow({ dp, isSelected, readOnly, language, onToggle }: DataPointRowProps) {
|
||||
return (
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
{!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>
|
||||
)}
|
||||
<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>
|
||||
<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>
|
||||
{(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>
|
||||
)}
|
||||
{dp.thirdPartyRecipients.length > 0 && (
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
<strong>Drittanbieter:</strong>{' '}
|
||||
{dp.thirdPartyRecipients.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user