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

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

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

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

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

View File

@@ -0,0 +1,288 @@
'use client'
import React, { useState } from 'react'
import {
DSRCommunication,
CommunicationType,
CommunicationChannel,
DSRSendCommunicationRequest
} from '@/lib/sdk/dsr/types'
interface DSRCommunicationLogProps {
communications: DSRCommunication[]
onSendMessage?: (message: DSRSendCommunicationRequest) => Promise<void>
isLoading?: boolean
}
const CHANNEL_ICONS: Record<CommunicationChannel, string> = {
email: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
letter: 'M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5',
phone: 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z',
portal: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
internal_note: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z'
}
const TYPE_COLORS: Record<CommunicationType, { bg: string; border: string; icon: string }> = {
incoming: { bg: 'bg-blue-50', border: 'border-blue-200', icon: 'text-blue-600' },
outgoing: { bg: 'bg-green-50', border: 'border-green-200', icon: 'text-green-600' },
internal: { bg: 'bg-gray-50', border: 'border-gray-200', icon: 'text-gray-600' }
}
const CHANNEL_LABELS: Record<CommunicationChannel, string> = {
email: 'E-Mail',
letter: 'Brief',
phone: 'Telefon',
portal: 'Portal',
internal_note: 'Interne Notiz'
}
function formatDate(dateString: string): string {
const date = new Date(dateString)
return date.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
export function DSRCommunicationLog({
communications,
onSendMessage,
isLoading = false
}: DSRCommunicationLogProps) {
const [showComposeForm, setShowComposeForm] = useState(false)
const [newMessage, setNewMessage] = useState<DSRSendCommunicationRequest>({
type: 'outgoing',
channel: 'email',
subject: '',
content: ''
})
const [isSending, setIsSending] = useState(false)
const sortedCommunications = [...communications].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
const handleSendMessage = async () => {
if (!onSendMessage || !newMessage.content.trim()) return
setIsSending(true)
try {
await onSendMessage(newMessage)
setNewMessage({ type: 'outgoing', channel: 'email', subject: '', content: '' })
setShowComposeForm(false)
} catch (error) {
console.error('Failed to send message:', error)
} finally {
setIsSending(false)
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900">Kommunikation</h3>
{onSendMessage && (
<button
onClick={() => setShowComposeForm(!showComposeForm)}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Nachricht
</button>
)}
</div>
{/* Compose Form */}
{showComposeForm && (
<div className="bg-white rounded-xl border border-gray-200 p-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
<select
value={newMessage.type}
onChange={(e) => setNewMessage({ ...newMessage, type: e.target.value as CommunicationType })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="outgoing">Ausgehend</option>
<option value="incoming">Eingehend</option>
<option value="internal">Interne Notiz</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kanal</label>
<select
value={newMessage.channel}
onChange={(e) => setNewMessage({ ...newMessage, channel: e.target.value as CommunicationChannel })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="email">E-Mail</option>
<option value="letter">Brief</option>
<option value="phone">Telefon</option>
<option value="portal">Portal</option>
<option value="internal_note">Interne Notiz</option>
</select>
</div>
</div>
{newMessage.type !== 'internal' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Betreff</label>
<input
type="text"
value={newMessage.subject || ''}
onChange={(e) => setNewMessage({ ...newMessage, subject: e.target.value })}
placeholder="Betreff eingeben..."
className="w-full px-3 py-2 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">Inhalt</label>
<textarea
value={newMessage.content}
onChange={(e) => setNewMessage({ ...newMessage, content: e.target.value })}
placeholder="Nachricht eingeben..."
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
/>
</div>
<div className="flex items-center justify-end gap-3">
<button
onClick={() => setShowComposeForm(false)}
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSendMessage}
disabled={!newMessage.content.trim() || isSending}
className={`
px-4 py-2 rounded-lg font-medium transition-colors
${newMessage.content.trim() && !isSending
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}
`}
>
{isSending ? 'Sende...' : 'Speichern'}
</button>
</div>
</div>
)}
{/* Communication Timeline */}
{isLoading ? (
<div className="flex items-center justify-center py-8">
<svg className="animate-spin w-6 h-6 text-purple-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
) : sortedCommunications.length === 0 ? (
<div className="bg-gray-50 rounded-xl p-8 text-center">
<div className="w-12 h-12 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-3">
<svg className="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
</div>
<p className="text-gray-500">Noch keine Kommunikation vorhanden</p>
</div>
) : (
<div className="relative">
{/* Timeline Line */}
<div className="absolute left-5 top-0 bottom-0 w-0.5 bg-gray-200" />
{/* Timeline Items */}
<div className="space-y-4">
{sortedCommunications.map((comm) => {
const colors = TYPE_COLORS[comm.type]
return (
<div key={comm.id} className="relative pl-12">
{/* Timeline Dot */}
<div className={`
absolute left-3 w-5 h-5 rounded-full border-2 ${colors.border} ${colors.bg}
flex items-center justify-center
`}>
<svg className={`w-3 h-3 ${colors.icon}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={CHANNEL_ICONS[comm.channel]} />
</svg>
</div>
{/* Content Card */}
<div className={`${colors.bg} border ${colors.border} rounded-xl p-4`}>
{/* Header */}
<div className="flex items-start justify-between mb-2">
<div>
<div className="flex items-center gap-2">
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${colors.bg} ${colors.icon} border ${colors.border}`}>
{comm.type === 'incoming' ? 'Eingehend' :
comm.type === 'outgoing' ? 'Ausgehend' : 'Intern'}
</span>
<span className="text-xs text-gray-500">
{CHANNEL_LABELS[comm.channel]}
</span>
</div>
{comm.subject && (
<div className="font-medium text-gray-900 mt-1">
{comm.subject}
</div>
)}
</div>
<div className="text-xs text-gray-500">
{formatDate(comm.createdAt)}
</div>
</div>
{/* Content */}
<div className="text-sm text-gray-700 whitespace-pre-wrap">
{comm.content}
</div>
{/* Attachments */}
{comm.attachments && comm.attachments.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-200">
<div className="text-xs text-gray-500 mb-2">Anhaenge:</div>
<div className="flex flex-wrap gap-2">
{comm.attachments.map((attachment, idx) => (
<a
key={idx}
href={attachment.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 px-2 py-1 bg-white rounded border border-gray-200 text-xs text-gray-600 hover:bg-gray-50"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
{attachment.name}
</a>
))}
</div>
</div>
)}
{/* Footer */}
{comm.sentBy && (
<div className="mt-2 text-xs text-gray-500">
Von: {comm.sentBy}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,304 @@
'use client'
import React, { useState } from 'react'
import { DSRDataExport, DSRType } from '@/lib/sdk/dsr/types'
interface DSRDataExportProps {
dsrId: string
dsrType: DSRType // 'access' or 'portability'
existingExport?: DSRDataExport
onGenerate?: (format: 'json' | 'csv' | 'xml' | 'pdf') => Promise<void>
onDownload?: () => Promise<void>
isGenerating?: boolean
}
const FORMAT_OPTIONS: {
value: 'json' | 'csv' | 'xml' | 'pdf'
label: string
description: string
icon: string
recommended?: boolean
}[] = [
{
value: 'json',
label: 'JSON',
description: 'Maschinenlesbar, ideal fuer technische Uebertragung',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4',
recommended: true
},
{
value: 'csv',
label: 'CSV',
description: 'Tabellen-Format, mit Excel oeffenbar',
icon: 'M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'
},
{
value: 'xml',
label: 'XML',
description: 'Strukturiertes Format fuer System-Integration',
icon: 'M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4'
},
{
value: 'pdf',
label: 'PDF',
description: 'Menschenlesbar, ideal fuer direkte Zusendung',
icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'
}
]
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
export function DSRDataExportComponent({
dsrId,
dsrType,
existingExport,
onGenerate,
onDownload,
isGenerating = false
}: DSRDataExportProps) {
const [selectedFormat, setSelectedFormat] = useState<'json' | 'csv' | 'xml' | 'pdf'>(
dsrType === 'portability' ? 'json' : 'pdf'
)
const [includeThirdParty, setIncludeThirdParty] = useState(true)
const [transferRecipient, setTransferRecipient] = useState('')
const [showTransferSection, setShowTransferSection] = useState(false)
const isPortability = dsrType === 'portability'
const handleGenerate = async () => {
if (onGenerate) {
await onGenerate(selectedFormat)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h3 className="text-lg font-semibold text-gray-900">
{isPortability ? 'Datenexport (Art. 20)' : 'Datenauskunft (Art. 15)'}
</h3>
<p className="text-sm text-gray-500 mt-1">
{isPortability
? 'Exportieren Sie die Daten in einem maschinenlesbaren Format zur Uebertragung'
: 'Erstellen Sie eine Uebersicht aller gespeicherten personenbezogenen Daten'
}
</p>
</div>
{/* Existing Export */}
{existingExport && existingExport.generatedAt && (
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<div className="flex items-start gap-4">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-green-800">Export vorhanden</div>
<div className="text-sm text-green-700 mt-1 space-y-1">
<div>Format: <span className="font-medium">{existingExport.format.toUpperCase()}</span></div>
<div>Erstellt: <span className="font-medium">{formatDate(existingExport.generatedAt)}</span></div>
{existingExport.fileName && (
<div>Datei: <span className="font-medium">{existingExport.fileName}</span></div>
)}
{existingExport.fileSize && (
<div>Groesse: <span className="font-medium">{formatFileSize(existingExport.fileSize)}</span></div>
)}
</div>
</div>
{onDownload && (
<button
onClick={onDownload}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Herunterladen
</button>
)}
</div>
</div>
)}
{/* Format Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Export-Format waehlen
</label>
<div className="grid grid-cols-2 gap-3">
{FORMAT_OPTIONS.map(format => (
<button
key={format.value}
onClick={() => setSelectedFormat(format.value)}
className={`
p-4 rounded-xl border-2 text-left transition-all
${selectedFormat === format.value
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}
`}
>
<div className="flex items-start gap-3">
<div className={`
w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0
${selectedFormat === format.value ? 'bg-purple-100' : 'bg-gray-100'}
`}>
<svg
className={`w-4 h-4 ${selectedFormat === format.value ? 'text-purple-600' : 'text-gray-500'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={format.icon} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`font-medium ${selectedFormat === format.value ? 'text-purple-700' : 'text-gray-900'}`}>
{format.label}
</span>
{format.recommended && isPortability && (
<span className="text-xs bg-purple-100 text-purple-700 px-1.5 py-0.5 rounded">
Empfohlen
</span>
)}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{format.description}
</div>
</div>
</div>
</button>
))}
</div>
</div>
{/* Options */}
<div className="space-y-4">
{/* Include Third-Party Data */}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeThirdParty}
onChange={(e) => setIncludeThirdParty(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">Drittanbieter-Daten einbeziehen</div>
<div className="text-sm text-gray-500">
Daten von externen Diensten (Google Analytics, etc.) mit exportieren
</div>
</div>
</label>
{/* Data Transfer (Art. 20 only) */}
{isPortability && (
<div className="pt-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={showTransferSection}
onChange={(e) => setShowTransferSection(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<div>
<div className="font-medium text-gray-900">Direkte Uebertragung an Dritten</div>
<div className="text-sm text-gray-500">
Daten direkt an einen anderen Verantwortlichen uebertragen
</div>
</div>
</label>
{showTransferSection && (
<div className="mt-3 ml-8 p-4 bg-gray-50 rounded-xl">
<label className="block text-sm font-medium text-gray-700 mb-1">
Empfaenger der Daten
</label>
<input
type="text"
value={transferRecipient}
onChange={(e) => setTransferRecipient(e.target.value)}
placeholder="Name des Unternehmens oder E-Mail-Adresse"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<p className="mt-2 text-xs text-gray-500">
Die Daten werden an den angegebenen Empfaenger uebermittelt,
sofern dies technisch machbar ist.
</p>
</div>
)}
</div>
)}
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-700">
<p className="font-medium">Enthaltene Datenkategorien</p>
<ul className="mt-2 space-y-1 list-disc list-inside">
<li>Stammdaten (Name, E-Mail, Adresse)</li>
<li>Nutzungsdaten (Login-Historie, Aktivitaeten)</li>
<li>Kommunikationsdaten (E-Mails, Support-Anfragen)</li>
{includeThirdParty && <li>Tracking-Daten (Analytics, Cookies)</li>}
</ul>
</div>
</div>
</div>
{/* Generate Button */}
{onGenerate && (
<div className="flex items-center justify-end gap-3">
<button
onClick={handleGenerate}
disabled={isGenerating}
className={`
px-6 py-2.5 rounded-lg font-medium transition-colors flex items-center gap-2
${!isGenerating
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}
`}
>
{isGenerating ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Export wird erstellt...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
{existingExport ? 'Neuen Export erstellen' : 'Export generieren'}
</>
)}
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,293 @@
'use client'
import React, { useState } from 'react'
import {
DSRErasureChecklist,
DSRErasureChecklistItem,
ERASURE_EXCEPTIONS
} from '@/lib/sdk/dsr/types'
interface DSRErasureChecklistProps {
checklist?: DSRErasureChecklist
onChange?: (checklist: DSRErasureChecklist) => void
readOnly?: boolean
}
export function DSRErasureChecklistComponent({
checklist,
onChange,
readOnly = false
}: DSRErasureChecklistProps) {
const [localChecklist, setLocalChecklist] = useState<DSRErasureChecklist>(() => {
if (checklist) return checklist
return {
items: ERASURE_EXCEPTIONS.map(exc => ({
...exc,
checked: false,
applies: false
})),
canProceedWithErasure: true
}
})
const handleItemChange = (
itemId: string,
field: 'checked' | 'applies' | 'notes',
value: boolean | string
) => {
const updatedItems = localChecklist.items.map(item => {
if (item.id !== itemId) return item
return { ...item, [field]: value }
})
// Calculate if erasure can proceed (no exceptions apply)
const canProceedWithErasure = !updatedItems.some(item => item.checked && item.applies)
const updatedChecklist: DSRErasureChecklist = {
...localChecklist,
items: updatedItems,
canProceedWithErasure
}
setLocalChecklist(updatedChecklist)
onChange?.(updatedChecklist)
}
const appliedExceptions = localChecklist.items.filter(item => item.checked && item.applies)
const allChecked = localChecklist.items.every(item => item.checked)
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Art. 17(3) Ausnahmen-Pruefung
</h3>
<p className="text-sm text-gray-500 mt-1">
Pruefen Sie, ob eine der Ausnahmen zur Loeschung zutrifft
</p>
</div>
{/* Status Badge */}
<div className={`
px-3 py-1.5 rounded-lg text-sm font-medium
${localChecklist.canProceedWithErasure
? 'bg-green-100 text-green-700 border border-green-200'
: 'bg-red-100 text-red-700 border border-red-200'
}
`}>
{localChecklist.canProceedWithErasure
? 'Loeschung moeglich'
: `${appliedExceptions.length} Ausnahme(n)`
}
</div>
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="text-sm text-blue-700">
<p className="font-medium">Hinweis</p>
<p className="mt-1">
Nach Art. 17(3) DSGVO bestehen Ausnahmen vom Loeschungsanspruch.
Pruefen Sie jeden Punkt und dokumentieren Sie, ob eine Ausnahme greift.
</p>
</div>
</div>
</div>
{/* Checklist Items */}
<div className="space-y-3">
{localChecklist.items.map((item, index) => (
<ChecklistItem
key={item.id}
item={item}
index={index}
readOnly={readOnly}
onChange={(field, value) => handleItemChange(item.id, field, value)}
/>
))}
</div>
{/* Summary */}
{allChecked && (
<div className={`
rounded-xl p-4 border
${localChecklist.canProceedWithErasure
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}
`}>
<div className="flex items-start gap-3">
<div className={`
w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
${localChecklist.canProceedWithErasure ? 'bg-green-100' : 'bg-red-100'}
`}>
{localChecklist.canProceedWithErasure ? (
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<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="M6 18L18 6M6 6l12 12" />
</svg>
)}
</div>
<div>
<div className={`font-medium ${localChecklist.canProceedWithErasure ? 'text-green-800' : 'text-red-800'}`}>
{localChecklist.canProceedWithErasure
? 'Alle Ausnahmen geprueft - Loeschung kann durchgefuehrt werden'
: 'Ausnahme(n) greifen - Loeschung nicht oder nur teilweise moeglich'
}
</div>
{!localChecklist.canProceedWithErasure && (
<ul className="mt-2 space-y-1">
{appliedExceptions.map(exc => (
<li key={exc.id} className="text-sm text-red-700">
- {exc.article}: {exc.label}
</li>
))}
</ul>
)}
</div>
</div>
</div>
)}
{/* Progress Indicator */}
{!allChecked && (
<div className="text-sm text-gray-500 text-center">
{localChecklist.items.filter(i => i.checked).length} von {localChecklist.items.length} Ausnahmen geprueft
</div>
)}
</div>
)
}
// Individual Checklist Item Component
function ChecklistItem({
item,
index,
readOnly,
onChange
}: {
item: DSRErasureChecklistItem
index: number
readOnly: boolean
onChange: (field: 'checked' | 'applies' | 'notes', value: boolean | string) => void
}) {
const [expanded, setExpanded] = useState(false)
return (
<div className={`
rounded-xl border transition-all
${item.checked
? item.applies
? 'border-red-200 bg-red-50'
: 'border-green-200 bg-green-50'
: 'border-gray-200 bg-white'
}
`}>
{/* Main Row */}
<div className="p-4">
<div className="flex items-start gap-4">
{/* Checkbox */}
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
checked={item.checked}
onChange={(e) => onChange('checked', e.target.checked)}
disabled={readOnly}
className="w-5 h-5 rounded border-gray-300 text-purple-600 focus:ring-purple-500 disabled:opacity-50"
/>
</label>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded">
{item.article}
</span>
<span className="font-medium text-gray-900">{item.label}</span>
</div>
<p className="text-sm text-gray-600">{item.description}</p>
</div>
{/* Toggle Expand */}
<button
onClick={() => setExpanded(!expanded)}
className="p-1 text-gray-400 hover:text-gray-600 rounded"
>
<svg
className={`w-5 h-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{/* Applies Toggle - Show when checked */}
{item.checked && (
<div className="mt-3 ml-9 flex items-center gap-4">
<span className="text-sm text-gray-600">Trifft diese Ausnahme zu?</span>
<div className="flex items-center gap-2">
<button
onClick={() => onChange('applies', false)}
disabled={readOnly}
className={`
px-3 py-1 text-sm rounded-lg transition-colors
${!item.applies
? 'bg-green-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}
${readOnly ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
Nein
</button>
<button
onClick={() => onChange('applies', true)}
disabled={readOnly}
className={`
px-3 py-1 text-sm rounded-lg transition-colors
${item.applies
? 'bg-red-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}
${readOnly ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
Ja
</button>
</div>
</div>
)}
</div>
{/* Expanded Notes Section */}
{expanded && (
<div className="px-4 pb-4 pt-0 border-t border-gray-100">
<div className="ml-9 mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Notizen / Begruendung
</label>
<textarea
value={item.notes || ''}
onChange={(e) => onChange('notes', e.target.value)}
disabled={readOnly}
placeholder="Dokumentieren Sie Ihre Pruefung..."
rows={3}
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 resize-none disabled:bg-gray-50 disabled:text-gray-500"
/>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,263 @@
'use client'
import React, { useState } from 'react'
import {
IdentityVerificationMethod,
DSRVerifyIdentityRequest
} from '@/lib/sdk/dsr/types'
interface DSRIdentityModalProps {
isOpen: boolean
onClose: () => void
onVerify: (verification: DSRVerifyIdentityRequest) => Promise<void>
requesterName: string
requesterEmail: string
}
const VERIFICATION_METHODS: {
value: IdentityVerificationMethod
label: string
description: string
icon: string
}[] = [
{
value: 'id_document',
label: 'Ausweisdokument',
description: 'Kopie von Personalausweis oder Reisepass',
icon: 'M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2'
},
{
value: 'email',
label: 'E-Mail-Bestaetigung',
description: 'Bestaetigung ueber verifizierte E-Mail-Adresse',
icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z'
},
{
value: 'existing_account',
label: 'Bestehendes Konto',
description: 'Anmeldung ueber bestehendes Kundenkonto',
icon: 'M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z'
},
{
value: 'phone',
label: 'Telefonische Bestaetigung',
description: 'Verifizierung per Telefonanruf',
icon: 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z'
},
{
value: 'postal',
label: 'Postalische Bestaetigung',
description: 'Bestaetigung per Brief',
icon: 'M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5m0 0l-1.14.76a2 2 0 01-2.22 0l-1.14-.76'
},
{
value: 'other',
label: 'Sonstige Methode',
description: 'Andere Verifizierungsmethode',
icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z'
}
]
export function DSRIdentityModal({
isOpen,
onClose,
onVerify,
requesterName,
requesterEmail
}: DSRIdentityModalProps) {
const [selectedMethod, setSelectedMethod] = useState<IdentityVerificationMethod | null>(null)
const [notes, setNotes] = useState('')
const [documentRef, setDocumentRef] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!isOpen) return null
const handleVerify = async () => {
if (!selectedMethod) {
setError('Bitte waehlen Sie eine Verifizierungsmethode')
return
}
setIsLoading(true)
setError(null)
try {
await onVerify({
method: selectedMethod,
notes: notes || undefined,
documentRef: documentRef || undefined
})
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Verifizierung fehlgeschlagen')
} finally {
setIsLoading(false)
}
}
const handleClose = () => {
setSelectedMethod(null)
setNotes('')
setDocumentRef('')
setError(null)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={handleClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
Identitaet verifizieren
</h2>
<button
onClick={handleClose}
className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="px-6 py-4 overflow-y-auto max-h-[60vh]">
{/* Requester Info */}
<div className="bg-gray-50 rounded-xl p-4 mb-6">
<div className="text-sm text-gray-500 mb-1">Antragsteller</div>
<div className="font-medium text-gray-900">{requesterName}</div>
<div className="text-sm text-gray-600">{requesterEmail}</div>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Verification Methods */}
<div className="space-y-2 mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Verifizierungsmethode
</label>
{VERIFICATION_METHODS.map(method => (
<button
key={method.value}
onClick={() => setSelectedMethod(method.value)}
className={`
w-full flex items-start gap-3 p-3 rounded-xl border-2 text-left transition-all
${selectedMethod === method.value
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
}
`}
>
<div className={`
w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0
${selectedMethod === method.value ? 'bg-purple-100' : 'bg-gray-100'}
`}>
<svg
className={`w-5 h-5 ${selectedMethod === method.value ? 'text-purple-600' : 'text-gray-500'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={method.icon} />
</svg>
</div>
<div className="flex-1 min-w-0">
<div className={`font-medium ${selectedMethod === method.value ? 'text-purple-700' : 'text-gray-900'}`}>
{method.label}
</div>
<div className="text-sm text-gray-500">
{method.description}
</div>
</div>
{selectedMethod === method.value && (
<svg className="w-5 h-5 text-purple-600 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>
{/* Document Reference */}
{selectedMethod === 'id_document' && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Dokumentenreferenz (optional)
</label>
<input
type="text"
value={documentRef}
onChange={(e) => setDocumentRef(e.target.value)}
placeholder="z.B. Datei-ID oder Speicherort"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
)}
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Notizen (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Weitere Informationen zur Verifizierung..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
/>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 flex items-center justify-end gap-3">
<button
onClick={handleClose}
className="px-4 py-2 text-gray-700 hover:bg-gray-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleVerify}
disabled={!selectedMethod || isLoading}
className={`
px-4 py-2 rounded-lg font-medium transition-colors
${selectedMethod && !isLoading
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}
`}
>
{isLoading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Verifiziere...
</span>
) : (
'Identitaet bestaetigen'
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,176 @@
'use client'
import React from 'react'
import { DSRStatus, DSR_STATUS_INFO } from '@/lib/sdk/dsr/types'
interface WorkflowStep {
id: DSRStatus
label: string
description?: string
}
const WORKFLOW_STEPS: WorkflowStep[] = [
{ id: 'intake', label: 'Eingang', description: 'Anfrage dokumentiert' },
{ id: 'identity_verification', label: 'ID-Pruefung', description: 'Identitaet verifizieren' },
{ id: 'processing', label: 'Bearbeitung', description: 'Anfrage bearbeiten' },
{ id: 'completed', label: 'Abschluss', description: 'Antwort versenden' }
]
interface DSRWorkflowStepperProps {
currentStatus: DSRStatus
onStepClick?: (status: DSRStatus) => void
className?: string
}
export function DSRWorkflowStepper({
currentStatus,
onStepClick,
className = ''
}: DSRWorkflowStepperProps) {
const currentIndex = WORKFLOW_STEPS.findIndex(s => s.id === currentStatus)
const isRejectedOrCancelled = currentStatus === 'rejected' || currentStatus === 'cancelled'
const getStepState = (index: number): 'completed' | 'current' | 'upcoming' => {
if (isRejectedOrCancelled) {
return index <= currentIndex ? 'completed' : 'upcoming'
}
if (index < currentIndex) return 'completed'
if (index === currentIndex) return 'current'
return 'upcoming'
}
return (
<div className={`${className}`}>
<div className="flex items-center justify-between">
{WORKFLOW_STEPS.map((step, index) => {
const state = getStepState(index)
const isLast = index === WORKFLOW_STEPS.length - 1
return (
<React.Fragment key={step.id}>
{/* Step */}
<div
className={`flex flex-col items-center ${
onStepClick && state !== 'upcoming' ? 'cursor-pointer' : ''
}`}
onClick={() => onStepClick && state !== 'upcoming' && onStepClick(step.id)}
>
{/* Circle */}
<div
className={`
w-10 h-10 rounded-full flex items-center justify-center font-medium text-sm
transition-all duration-200
${state === 'completed'
? 'bg-green-500 text-white'
: state === 'current'
? 'bg-purple-600 text-white ring-4 ring-purple-100'
: 'bg-gray-200 text-gray-400'
}
`}
>
{state === 'completed' ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
index + 1
)}
</div>
{/* Label */}
<div className="mt-2 text-center">
<div
className={`text-sm font-medium ${
state === 'current' ? 'text-purple-600' :
state === 'completed' ? 'text-green-600' : 'text-gray-400'
}`}
>
{step.label}
</div>
{step.description && (
<div className="text-xs text-gray-500 mt-0.5 hidden sm:block">
{step.description}
</div>
)}
</div>
</div>
{/* Connector Line */}
{!isLast && (
<div
className={`
flex-1 h-1 mx-2 rounded-full
${state === 'completed' || getStepState(index + 1) === 'completed' || getStepState(index + 1) === 'current'
? 'bg-green-500'
: 'bg-gray-200'
}
`}
/>
)}
</React.Fragment>
)
})}
</div>
{/* Rejected/Cancelled Badge */}
{isRejectedOrCancelled && (
<div className={`
mt-4 px-4 py-2 rounded-lg text-center text-sm font-medium
${currentStatus === 'rejected'
? 'bg-red-100 text-red-700 border border-red-200'
: 'bg-gray-100 text-gray-700 border border-gray-200'
}
`}>
{currentStatus === 'rejected' ? 'Anfrage wurde abgelehnt' : 'Anfrage wurde storniert'}
</div>
)}
</div>
)
}
// Compact version for list views
export function DSRWorkflowStepperCompact({
currentStatus,
className = ''
}: {
currentStatus: DSRStatus
className?: string
}) {
const statusInfo = DSR_STATUS_INFO[currentStatus]
const currentIndex = WORKFLOW_STEPS.findIndex(s => s.id === currentStatus)
const totalSteps = WORKFLOW_STEPS.length
const isTerminal = currentStatus === 'rejected' || currentStatus === 'cancelled' || currentStatus === 'completed'
return (
<div className={`flex items-center gap-2 ${className}`}>
{/* Mini progress dots */}
<div className="flex items-center gap-1">
{WORKFLOW_STEPS.map((step, index) => (
<div
key={step.id}
className={`
w-2 h-2 rounded-full transition-all
${index < currentIndex
? 'bg-green-500'
: index === currentIndex
? isTerminal
? currentStatus === 'completed'
? 'bg-green-500'
: currentStatus === 'rejected'
? 'bg-red-500'
: 'bg-gray-500'
: 'bg-purple-500'
: 'bg-gray-200'
}
`}
/>
))}
</div>
{/* Status label */}
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
)
}

View File

@@ -0,0 +1,9 @@
/**
* DSR Components Exports
*/
export { DSRWorkflowStepper, DSRWorkflowStepperCompact } from './DSRWorkflowStepper'
export { DSRIdentityModal } from './DSRIdentityModal'
export { DSRCommunicationLog } from './DSRCommunicationLog'
export { DSRErasureChecklistComponent } from './DSRErasureChecklist'
export { DSRDataExportComponent } from './DSRDataExport'