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:
288
admin-v2/components/sdk/dsr/DSRCommunicationLog.tsx
Normal file
288
admin-v2/components/sdk/dsr/DSRCommunicationLog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
304
admin-v2/components/sdk/dsr/DSRDataExport.tsx
Normal file
304
admin-v2/components/sdk/dsr/DSRDataExport.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
admin-v2/components/sdk/dsr/DSRErasureChecklist.tsx
Normal file
293
admin-v2/components/sdk/dsr/DSRErasureChecklist.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
263
admin-v2/components/sdk/dsr/DSRIdentityModal.tsx
Normal file
263
admin-v2/components/sdk/dsr/DSRIdentityModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
176
admin-v2/components/sdk/dsr/DSRWorkflowStepper.tsx
Normal file
176
admin-v2/components/sdk/dsr/DSRWorkflowStepper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
admin-v2/components/sdk/dsr/index.ts
Normal file
9
admin-v2/components/sdk/dsr/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user