Initial commit: breakpilot-compliance - Compliance SDK Platform

Services: Admin-Compliance, Backend-Compliance,
AI-Compliance-SDK, Consent-SDK, Developer-Portal,
PCA-Platform, DSMS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:28 +01:00
commit 4435e7ea0a
734 changed files with 251369 additions and 0 deletions

View File

@@ -0,0 +1,749 @@
'use client'
import React, { useState, useEffect } from 'react'
import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import {
DSRRequest,
DSR_TYPE_INFO,
DSR_STATUS_INFO,
getDaysRemaining,
isOverdue,
isUrgent,
DSRCommunication,
DSRVerifyIdentityRequest
} from '@/lib/sdk/dsr/types'
import { fetchSDKDSR, updateSDKDSRStatus } from '@/lib/sdk/dsr/api'
import {
DSRWorkflowStepper,
DSRIdentityModal,
DSRCommunicationLog,
DSRErasureChecklistComponent,
DSRDataExportComponent
} from '@/components/sdk/dsr'
// =============================================================================
// MOCK COMMUNICATIONS
// =============================================================================
const mockCommunications: DSRCommunication[] = [
{
id: 'comm-001',
dsrId: 'dsr-001',
type: 'outgoing',
channel: 'email',
subject: 'Eingangsbestaetigung Ihrer Anfrage',
content: 'Sehr geehrte(r) Antragsteller(in),\n\nwir bestaetigen den Eingang Ihrer Anfrage und werden diese innerhalb der gesetzlichen Frist bearbeiten.\n\nMit freundlichen Gruessen\nIhr Datenschutz-Team',
sentAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
sentBy: 'System',
createdAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
createdBy: 'System'
},
{
id: 'comm-002',
dsrId: 'dsr-001',
type: 'outgoing',
channel: 'email',
subject: 'Identitaetspruefung erforderlich',
content: 'Sehr geehrte(r) Antragsteller(in),\n\nbitte senden Sie uns zur Bearbeitung Ihrer Anfrage einen Identitaetsnachweis zu.\n\nMit freundlichen Gruessen',
sentAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
sentBy: 'DSB Mueller',
createdAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(),
createdBy: 'DSB Mueller'
}
]
// =============================================================================
// COMPONENTS
// =============================================================================
function StatusBadge({ status }: { status: string }) {
const info = DSR_STATUS_INFO[status as keyof typeof DSR_STATUS_INFO]
if (!info) return null
return (
<span className={`px-3 py-1.5 text-sm font-medium rounded-lg ${info.bgColor} ${info.color} border ${info.borderColor}`}>
{info.label}
</span>
)
}
function DeadlineDisplay({ request }: { request: DSRRequest }) {
const daysRemaining = getDaysRemaining(request.deadline.currentDeadline)
const overdue = isOverdue(request)
const urgent = isUrgent(request)
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
if (isTerminal) {
return (
<div className="text-gray-500">
<div className="text-sm">Abgeschlossen am</div>
<div className="text-lg font-semibold">
{request.completedAt
? new Date(request.completedAt).toLocaleDateString('de-DE')
: '-'
}
</div>
</div>
)
}
return (
<div className={`${overdue ? 'text-red-600' : urgent ? 'text-orange-600' : 'text-gray-900'}`}>
<div className="text-sm">Frist</div>
<div className="text-2xl font-bold">
{overdue
? `${Math.abs(daysRemaining)} Tage ueberfaellig`
: `${daysRemaining} Tage`
}
</div>
<div className="text-xs text-gray-500 mt-1">
bis {new Date(request.deadline.currentDeadline).toLocaleDateString('de-DE')}
</div>
{request.deadline.extended && (
<div className="text-xs text-purple-600 mt-1">
(Verlaengert)
</div>
)}
</div>
)
}
function ActionButtons({
request,
onVerifyIdentity,
onExtendDeadline,
onComplete,
onReject,
onAssign
}: {
request: DSRRequest
onVerifyIdentity: () => void
onExtendDeadline: () => void
onComplete: () => void
onReject: () => void
onAssign: () => void
}) {
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
if (isTerminal) {
return (
<div className="space-y-2">
<button className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm">
PDF exportieren
</button>
</div>
)
}
return (
<div className="space-y-2">
{!request.identityVerification.verified && (
<button
onClick={onVerifyIdentity}
className="w-full px-4 py-2 bg-yellow-500 text-white hover:bg-yellow-600 rounded-lg transition-colors text-sm font-medium"
>
Identitaet verifizieren
</button>
)}
<button
onClick={onAssign}
className="w-full px-4 py-2 text-purple-600 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors text-sm"
>
{request.assignment.assignedTo ? 'Neu zuweisen' : 'Zuweisen'}
</button>
<button
onClick={onExtendDeadline}
className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm"
>
Frist verlaengern
</button>
<div className="border-t border-gray-200 pt-2 mt-2">
<button
onClick={onComplete}
className="w-full px-4 py-2 bg-green-600 text-white hover:bg-green-700 rounded-lg transition-colors text-sm font-medium"
>
Abschliessen
</button>
<button
onClick={onReject}
className="w-full mt-2 px-4 py-2 text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors text-sm"
>
Ablehnen
</button>
</div>
</div>
)
}
function AuditLog({ request }: { request: DSRRequest }) {
type AuditEvent = { action: string; timestamp: string; user: string }
const events: AuditEvent[] = [
{ action: 'Erstellt', timestamp: request.createdAt, user: request.createdBy }
]
if (request.assignment.assignedAt) {
events.push({
action: `Zugewiesen an ${request.assignment.assignedTo}`,
timestamp: request.assignment.assignedAt,
user: request.assignment.assignedBy || 'System'
})
}
if (request.identityVerification.verifiedAt) {
events.push({
action: 'Identitaet verifiziert',
timestamp: request.identityVerification.verifiedAt,
user: request.identityVerification.verifiedBy || 'System'
})
}
if (request.completedAt) {
events.push({
action: request.status === 'rejected' ? 'Abgelehnt' : 'Abgeschlossen',
timestamp: request.completedAt,
user: request.updatedBy || 'System'
})
}
return (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-700">Aktivitaeten</h4>
<div className="space-y-2">
{events.map((event, idx) => (
<div key={idx} className="flex items-start gap-2 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 mt-1.5 flex-shrink-0" />
<div>
<div className="text-gray-900">{event.action}</div>
<div className="text-gray-500">
{new Date(event.timestamp).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
{' - '}
{event.user}
</div>
</div>
</div>
))}
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function DSRDetailPage() {
const params = useParams()
const router = useRouter()
const requestId = params.requestId as string
const [request, setRequest] = useState<DSRRequest | null>(null)
const [communications, setCommunications] = useState<DSRCommunication[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showIdentityModal, setShowIdentityModal] = useState(false)
const [activeContentTab, setActiveContentTab] = useState<'details' | 'communication' | 'type-specific'>('details')
// Load data from SDK backend
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const found = await fetchSDKDSR(requestId)
if (found) {
setRequest(found)
// Communications are loaded as mock for now (no backend API yet)
setCommunications(mockCommunications.filter(c => c.dsrId === requestId))
}
} catch (error) {
console.error('Failed to load DSR:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [requestId])
const handleVerifyIdentity = async (verification: DSRVerifyIdentityRequest) => {
if (!request) return
try {
await updateSDKDSRStatus(request.id, 'verified')
setRequest({
...request,
identityVerification: {
verified: true,
method: verification.method,
verifiedAt: new Date().toISOString(),
verifiedBy: 'Current User',
notes: verification.notes
},
status: request.status === 'identity_verification' ? 'processing' : request.status
})
} catch (err) {
console.error('Failed to verify identity:', err)
// Still update locally as fallback
setRequest({
...request,
identityVerification: {
verified: true,
method: verification.method,
verifiedAt: new Date().toISOString(),
verifiedBy: 'Current User',
notes: verification.notes
},
status: request.status === 'identity_verification' ? 'processing' : request.status
})
}
}
const handleSendCommunication = async (message: any) => {
const newComm: DSRCommunication = {
id: `comm-${Date.now()}`,
dsrId: requestId,
...message,
createdAt: new Date().toISOString(),
createdBy: 'Current User',
sentAt: message.type === 'outgoing' ? new Date().toISOString() : undefined,
sentBy: message.type === 'outgoing' ? 'Current User' : undefined
}
setCommunications(prev => [newComm, ...prev])
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 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>
)
}
if (!request) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-red-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Anfrage nicht gefunden</h3>
<p className="mt-2 text-gray-500">
Die angeforderte DSR-Anfrage existiert nicht oder wurde geloescht.
</p>
<Link
href="/sdk/dsr"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Zurueck zur Uebersicht
</Link>
</div>
)
}
const typeInfo = DSR_TYPE_INFO[request.type]
const overdue = isOverdue(request)
const urgent = isUrgent(request)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/sdk/dsr"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</Link>
<div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 font-mono">{request.referenceNumber}</span>
<span className={`px-2 py-1 text-xs rounded-full ${typeInfo.bgColor} ${typeInfo.color}`}>
{typeInfo.article} {typeInfo.label}
</span>
</div>
<h1 className="text-2xl font-bold text-gray-900 mt-1">
{request.requester.name}
</h1>
</div>
</div>
<button className="flex items-center gap-2 px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Exportieren
</button>
</div>
{/* Workflow Stepper */}
<div className={`
bg-white rounded-xl border-2 p-6
${overdue ? 'border-red-200' : urgent ? 'border-orange-200' : 'border-gray-200'}
`}>
<DSRWorkflowStepper currentStatus={request.status} />
</div>
{/* Main Content: 2/3 + 1/3 Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - 2/3 */}
<div className="lg:col-span-2 space-y-6">
{/* Content Tabs */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="border-b border-gray-200">
<nav className="flex -mb-px">
{[
{ id: 'details', label: 'Details' },
{ id: 'communication', label: 'Kommunikation' },
{ id: 'type-specific', label: typeInfo.labelShort }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveContentTab(tab.id as any)}
className={`
px-6 py-4 text-sm font-medium border-b-2 transition-colors
${activeContentTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
{tab.label}
</button>
))}
</nav>
</div>
<div className="p-6">
{/* Details Tab */}
{activeContentTab === 'details' && (
<div className="space-y-6">
{/* Request Info */}
<div className="grid grid-cols-2 gap-6">
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Antragsteller</h4>
<div className="space-y-2">
<div className="font-medium text-gray-900">{request.requester.name}</div>
<div className="text-sm text-gray-600">{request.requester.email}</div>
{request.requester.phone && (
<div className="text-sm text-gray-600">{request.requester.phone}</div>
)}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Eingereicht</h4>
<div className="space-y-2">
<div className="font-medium text-gray-900">
{new Date(request.receivedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric'
})}
</div>
<div className="text-sm text-gray-600">
Quelle: {request.source === 'web_form' ? 'Kontaktformular' :
request.source === 'email' ? 'E-Mail' :
request.source === 'letter' ? 'Brief' :
request.source === 'phone' ? 'Telefon' : request.source}
</div>
</div>
</div>
</div>
{/* Identity Verification */}
<div className={`
p-4 rounded-xl border
${request.identityVerification.verified
? 'bg-green-50 border-green-200'
: 'bg-yellow-50 border-yellow-200'
}
`}>
<div className="flex items-start gap-3">
<div className={`
w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
${request.identityVerification.verified ? 'bg-green-100' : 'bg-yellow-100'}
`}>
{request.identityVerification.verified ? (
<svg className="w-4 h-4 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-4 h-4 text-yellow-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>
<div className="flex-1">
<div className={`font-medium ${request.identityVerification.verified ? 'text-green-800' : 'text-yellow-800'}`}>
{request.identityVerification.verified
? 'Identitaet verifiziert'
: 'Identitaetspruefung ausstehend'
}
</div>
{request.identityVerification.verified && (
<div className="text-sm text-green-700 mt-1">
Methode: {request.identityVerification.method === 'id_document' ? 'Ausweisdokument' :
request.identityVerification.method === 'email' ? 'E-Mail' :
request.identityVerification.method === 'existing_account' ? 'Bestehendes Konto' :
request.identityVerification.method}
{' | '}
{new Date(request.identityVerification.verifiedAt!).toLocaleDateString('de-DE')}
</div>
)}
</div>
{!request.identityVerification.verified && (
<button
onClick={() => setShowIdentityModal(true)}
className="px-3 py-1.5 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors text-sm"
>
Jetzt pruefen
</button>
)}
</div>
</div>
{/* Request Text */}
{request.requestText && (
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Anfragetext</h4>
<div className="bg-gray-50 rounded-xl p-4 text-gray-700 whitespace-pre-wrap">
{request.requestText}
</div>
</div>
)}
{/* Notes */}
{request.notes && (
<div>
<h4 className="text-sm font-medium text-gray-500 mb-2">Notizen</h4>
<div className="bg-gray-50 rounded-xl p-4 text-gray-700">
{request.notes}
</div>
</div>
)}
</div>
)}
{/* Communication Tab */}
{activeContentTab === 'communication' && (
<DSRCommunicationLog
communications={communications}
onSendMessage={handleSendCommunication}
/>
)}
{/* Type-Specific Tab */}
{activeContentTab === 'type-specific' && (
<div>
{/* Art. 17 - Erasure */}
{request.type === 'erasure' && (
<DSRErasureChecklistComponent
checklist={request.erasureChecklist}
onChange={(checklist) => setRequest({ ...request, erasureChecklist: checklist })}
/>
)}
{/* Art. 15/20 - Data Export */}
{(request.type === 'access' || request.type === 'portability') && (
<DSRDataExportComponent
dsrId={request.id}
dsrType={request.type}
existingExport={request.dataExport}
onGenerate={async (format) => {
// Mock generation
setRequest({
...request,
dataExport: {
format,
generatedAt: new Date().toISOString(),
generatedBy: 'Current User',
fileName: `datenexport_${request.referenceNumber}.${format}`,
fileSize: 125000,
includesThirdPartyData: true
}
})
}}
/>
)}
{/* Art. 16 - Rectification */}
{request.type === 'rectification' && request.rectificationDetails && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Zu korrigierende Daten</h3>
<div className="space-y-3">
{request.rectificationDetails.fieldsToCorrect.map((field, idx) => (
<div key={idx} className="bg-gray-50 rounded-xl p-4">
<div className="font-medium text-gray-900 mb-2">{field.field}</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<div className="text-gray-500 mb-1">Aktueller Wert</div>
<div className="text-red-600 line-through">{field.currentValue}</div>
</div>
<div>
<div className="text-gray-500 mb-1">Angeforderter Wert</div>
<div className="text-green-600">{field.requestedValue}</div>
</div>
</div>
{field.corrected && (
<div className="mt-2 text-xs text-green-600">
Korrigiert am {new Date(field.correctedAt!).toLocaleDateString('de-DE')}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Art. 21 - Objection */}
{request.type === 'objection' && request.objectionDetails && (
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900">Widerspruchsdetails</h3>
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 rounded-xl p-4">
<div className="text-sm text-gray-500 mb-1">Verarbeitungszweck</div>
<div className="font-medium">{request.objectionDetails.processingPurpose}</div>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="text-sm text-gray-500 mb-1">Rechtsgrundlage</div>
<div className="font-medium">{request.objectionDetails.legalBasis}</div>
</div>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<div className="text-sm text-gray-500 mb-1">Widerspruchsgruende</div>
<div>{request.objectionDetails.objectionGrounds}</div>
</div>
{request.objectionDetails.decision !== 'pending' && (
<div className={`
rounded-xl p-4 border
${request.objectionDetails.decision === 'accepted'
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}
`}>
<div className={`font-medium ${
request.objectionDetails.decision === 'accepted' ? 'text-green-800' : 'text-red-800'
}`}>
Widerspruch {request.objectionDetails.decision === 'accepted' ? 'angenommen' : 'abgelehnt'}
</div>
{request.objectionDetails.decisionReason && (
<div className={`text-sm mt-1 ${
request.objectionDetails.decision === 'accepted' ? 'text-green-700' : 'text-red-700'
}`}>
{request.objectionDetails.decisionReason}
</div>
)}
</div>
)}
</div>
)}
{/* Default for restriction */}
{request.type === 'restriction' && (
<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" 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>
<div className="font-medium text-blue-800">Einschraenkung der Verarbeitung</div>
<p className="text-sm text-blue-700 mt-1">
Markieren Sie die betroffenen Daten im System als eingeschraenkt.
Die Daten duerfen nur noch gespeichert, aber nicht mehr verarbeitet werden.
</p>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Right Column - 1/3 Sidebar */}
<div className="space-y-6">
{/* Status Card */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900">Status</h3>
<StatusBadge status={request.status} />
</div>
<div className="border-t border-gray-100 pt-4">
<DeadlineDisplay request={request} />
</div>
{/* Priority */}
<div className="border-t border-gray-100 pt-4">
<div className="text-sm text-gray-500 mb-1">Prioritaet</div>
<div className={`
inline-flex px-2 py-1 text-sm font-medium rounded-lg
${request.priority === 'critical' ? 'bg-red-100 text-red-700' :
request.priority === 'high' ? 'bg-orange-100 text-orange-700' :
request.priority === 'normal' ? 'bg-gray-100 text-gray-700' :
'bg-blue-100 text-blue-700'
}
`}>
{request.priority === 'critical' ? 'Kritisch' :
request.priority === 'high' ? 'Hoch' :
request.priority === 'normal' ? 'Normal' : 'Niedrig'}
</div>
</div>
{/* Assignment */}
<div className="border-t border-gray-100 pt-4">
<div className="text-sm text-gray-500 mb-1">Zugewiesen an</div>
<div className="font-medium text-gray-900">
{request.assignment.assignedTo || 'Nicht zugewiesen'}
</div>
</div>
</div>
{/* Actions Card */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-medium text-gray-900 mb-4">Aktionen</h3>
<ActionButtons
request={request}
onVerifyIdentity={() => setShowIdentityModal(true)}
onExtendDeadline={() => alert('Fristverlaengerung - Coming soon')}
onComplete={() => alert('Abschliessen - Coming soon')}
onReject={() => alert('Ablehnen - Coming soon')}
onAssign={() => alert('Zuweisen - Coming soon')}
/>
</div>
{/* Audit Log Card */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<AuditLog request={request} />
</div>
</div>
</div>
{/* Identity Modal */}
<DSRIdentityModal
isOpen={showIdentityModal}
onClose={() => setShowIdentityModal(false)}
onVerify={handleVerifyIdentity}
requesterName={request.requester.name}
requesterEmail={request.requester.email}
/>
</div>
)
}

View File

@@ -0,0 +1,518 @@
'use client'
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import {
DSRType,
DSRSource,
DSRPriority,
DSR_TYPE_INFO,
DSRCreateRequest
} from '@/lib/sdk/dsr/types'
import { createSDKDSR } from '@/lib/sdk/dsr/api'
// =============================================================================
// TYPES
// =============================================================================
interface FormData {
type: DSRType | ''
requesterName: string
requesterEmail: string
requesterPhone: string
requesterAddress: string
source: DSRSource | ''
sourceDetails: string
requestText: string
priority: DSRPriority
customerId: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TypeSelector({
selectedType,
onSelect
}: {
selectedType: DSRType | ''
onSelect: (type: DSRType) => void
}) {
return (
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
Art der Anfrage <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{Object.entries(DSR_TYPE_INFO).map(([type, info]) => (
<button
key={type}
type="button"
onClick={() => onSelect(type as DSRType)}
className={`
p-4 rounded-xl border-2 text-left transition-all
${selectedType === type
? '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-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0
${selectedType === type ? 'bg-purple-100' : info.bgColor}
`}>
<span className={`text-sm font-bold ${selectedType === type ? 'text-purple-600' : info.color}`}>
{info.article.split(' ')[1]}
</span>
</div>
<div className="flex-1 min-w-0">
<div className={`font-medium ${selectedType === type ? 'text-purple-700' : 'text-gray-900'}`}>
{info.labelShort}
</div>
<div className="text-xs text-gray-500 mt-0.5">
{info.article}
</div>
</div>
</div>
</button>
))}
</div>
{selectedType && (
<div className={`p-4 rounded-xl ${DSR_TYPE_INFO[selectedType].bgColor} border border-gray-200`}>
<div className={`font-medium ${DSR_TYPE_INFO[selectedType].color}`}>
{DSR_TYPE_INFO[selectedType].label}
</div>
<p className="text-sm text-gray-600 mt-1">
{DSR_TYPE_INFO[selectedType].description}
</p>
<div className="text-xs text-gray-500 mt-2">
Standardfrist: {DSR_TYPE_INFO[selectedType].defaultDeadlineDays} Tage
{DSR_TYPE_INFO[selectedType].maxExtensionMonths > 0 && (
<> | Verlaengerbar um {DSR_TYPE_INFO[selectedType].maxExtensionMonths} Monate</>
)}
</div>
</div>
)}
</div>
)
}
function SourceSelector({
selectedSource,
sourceDetails,
onSourceChange,
onDetailsChange
}: {
selectedSource: DSRSource | ''
sourceDetails: string
onSourceChange: (source: DSRSource) => void
onDetailsChange: (details: string) => void
}) {
const sources: { value: DSRSource; label: string; icon: string }[] = [
{ value: 'web_form', label: 'Webformular', icon: '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' },
{ value: 'email', label: 'E-Mail', 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: 'letter', label: '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.5' },
{ value: 'phone', label: 'Telefon', 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: 'in_person', label: 'Persoenlich', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
{ value: 'other', label: 'Sonstiges', 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' }
]
return (
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700">
Quelle der Anfrage <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
{sources.map(source => (
<button
key={source.value}
type="button"
onClick={() => onSourceChange(source.value)}
className={`
p-3 rounded-xl border-2 text-center transition-all
${selectedSource === source.value
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}
`}
>
<svg
className={`w-6 h-6 mx-auto ${selectedSource === source.value ? 'text-purple-600' : 'text-gray-400'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={source.icon} />
</svg>
<div className={`text-xs mt-1 ${selectedSource === source.value ? 'text-purple-600 font-medium' : 'text-gray-500'}`}>
{source.label}
</div>
</button>
))}
</div>
{selectedSource && (
<input
type="text"
value={sourceDetails}
onChange={(e) => onDetailsChange(e.target.value)}
placeholder={
selectedSource === 'web_form' ? 'z.B. Kontaktformular auf website.de' :
selectedSource === 'email' ? 'z.B. info@firma.de' :
selectedSource === 'phone' ? 'z.B. Anruf am 22.01.2025' :
'Weitere Details zur Quelle'
}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
)}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function NewDSRPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [formData, setFormData] = useState<FormData>({
type: '',
requesterName: '',
requesterEmail: '',
requesterPhone: '',
requesterAddress: '',
source: '',
sourceDetails: '',
requestText: '',
priority: 'normal',
customerId: ''
})
const updateField = <K extends keyof FormData>(field: K, value: FormData[K]) => {
setFormData(prev => ({ ...prev, [field]: value }))
// Clear error when field is updated
if (errors[field]) {
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[field]
return newErrors
})
}
}
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
if (!formData.type) {
newErrors.type = 'Bitte waehlen Sie den Anfragetyp'
}
if (!formData.requesterName.trim()) {
newErrors.requesterName = 'Name ist erforderlich'
}
if (!formData.requesterEmail.trim()) {
newErrors.requesterEmail = 'E-Mail ist erforderlich'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.requesterEmail)) {
newErrors.requesterEmail = 'Bitte geben Sie eine gueltige E-Mail-Adresse ein'
}
if (!formData.source) {
newErrors.source = 'Bitte waehlen Sie die Quelle der Anfrage'
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!validate()) return
setIsSubmitting(true)
try {
// Create DSR request
const request: DSRCreateRequest = {
type: formData.type as DSRType,
requester: {
name: formData.requesterName,
email: formData.requesterEmail,
phone: formData.requesterPhone || undefined,
address: formData.requesterAddress || undefined,
customerId: formData.customerId || undefined
},
source: formData.source as DSRSource,
sourceDetails: formData.sourceDetails || undefined,
requestText: formData.requestText || undefined,
priority: formData.priority
}
await createSDKDSR(request)
// Redirect to DSR list
router.push('/sdk/dsr')
} catch (error) {
console.error('Failed to create DSR:', error)
setErrors({ submit: 'Fehler beim Erstellen der Anfrage. Bitte versuchen Sie es erneut.' })
} finally {
setIsSubmitting(false)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link
href="/sdk/dsr"
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900">Neue Anfrage erfassen</h1>
<p className="text-gray-500 mt-1">
Erfassen Sie eine neue Betroffenenanfrage (Art. 15-21 DSGVO)
</p>
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-8">
{/* Type Selection */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<TypeSelector
selectedType={formData.type}
onSelect={(type) => updateField('type', type)}
/>
{errors.type && (
<p className="mt-2 text-sm text-red-600">{errors.type}</p>
)}
</div>
{/* Requester Information */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Antragsteller</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.requesterName}
onChange={(e) => updateField('requesterName', e.target.value)}
placeholder="Max Mustermann"
className={`
w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500
${errors.requesterName ? 'border-red-300' : 'border-gray-300'}
`}
/>
{errors.requesterName && (
<p className="mt-1 text-sm text-red-600">{errors.requesterName}</p>
)}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
E-Mail <span className="text-red-500">*</span>
</label>
<input
type="email"
value={formData.requesterEmail}
onChange={(e) => updateField('requesterEmail', e.target.value)}
placeholder="max.mustermann@example.de"
className={`
w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500
${errors.requesterEmail ? 'border-red-300' : 'border-gray-300'}
`}
/>
{errors.requesterEmail && (
<p className="mt-1 text-sm text-red-600">{errors.requesterEmail}</p>
)}
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefon (optional)
</label>
<input
type="tel"
value={formData.requesterPhone}
onChange={(e) => updateField('requesterPhone', e.target.value)}
placeholder="+49 170 1234567"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
{/* Customer ID */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kunden-ID (optional)
</label>
<input
type="text"
value={formData.customerId}
onChange={(e) => updateField('customerId', e.target.value)}
placeholder="Falls bekannt"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
{/* Address */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Adresse (optional)
</label>
<textarea
value={formData.requesterAddress}
onChange={(e) => updateField('requesterAddress', e.target.value)}
placeholder="Strasse, PLZ, Ort"
rows={2}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
/>
</div>
</div>
{/* Source Information */}
<div className="bg-white rounded-xl border border-gray-200 p-6 space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Anfrage-Details</h2>
<SourceSelector
selectedSource={formData.source}
sourceDetails={formData.sourceDetails}
onSourceChange={(source) => updateField('source', source)}
onDetailsChange={(details) => updateField('sourceDetails', details)}
/>
{errors.source && (
<p className="mt-1 text-sm text-red-600">{errors.source}</p>
)}
{/* Request Text */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Anfrage-Text (optional)
</label>
<textarea
value={formData.requestText}
onChange={(e) => updateField('requestText', e.target.value)}
placeholder="Kopieren Sie hier den Text der Anfrage ein..."
rows={5}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-none"
/>
<p className="mt-1 text-xs text-gray-500">
Originaler Wortlaut der Anfrage fuer die Dokumentation
</p>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Prioritaet
</label>
<div className="flex gap-2">
{[
{ value: 'low', label: 'Niedrig', color: 'bg-blue-100 text-blue-700 border-blue-200' },
{ value: 'normal', label: 'Normal', color: 'bg-gray-100 text-gray-700 border-gray-200' },
{ value: 'high', label: 'Hoch', color: 'bg-orange-100 text-orange-700 border-orange-200' },
{ value: 'critical', label: 'Kritisch', color: 'bg-red-100 text-red-700 border-red-200' }
].map(priority => (
<button
key={priority.value}
type="button"
onClick={() => updateField('priority', priority.value as DSRPriority)}
className={`
px-4 py-2 rounded-lg border-2 text-sm font-medium transition-all
${formData.priority === priority.value
? priority.color + ' border-current'
: 'bg-white text-gray-500 border-gray-200 hover:border-gray-300'
}
`}
>
{priority.label}
</button>
))}
</div>
</div>
</div>
{/* Submit Error */}
{errors.submit && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<div className="flex items-center gap-3">
<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 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-red-700">{errors.submit}</p>
</div>
</div>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-4">
<Link
href="/sdk/dsr"
className="px-6 py-2.5 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Abbrechen
</Link>
<button
type="submit"
disabled={isSubmitting}
className={`
px-6 py-2.5 rounded-lg font-medium transition-colors flex items-center gap-2
${!isSubmitting
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}
`}
>
{isSubmitting ? (
<>
<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>
Wird erstellt...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Anfrage erfassen
</>
)}
</button>
</div>
</form>
{/* 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>
<h4 className="font-medium text-blue-800">Hinweis zur Eingangsbestaetigung</h4>
<p className="text-sm text-blue-700 mt-1">
Nach Erfassung der Anfrage wird automatisch eine Eingangsbestaetigung erstellt.
Sie koennen diese im naechsten Schritt an den Antragsteller senden.
Die gesetzliche Frist beginnt mit dem Eingangsdatum.
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,592 @@
'use client'
import React, { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
DSRRequest,
DSRType,
DSRStatus,
DSRStatistics,
DSR_TYPE_INFO,
DSR_STATUS_INFO,
getDaysRemaining,
isOverdue,
isUrgent
} from '@/lib/sdk/dsr/types'
import { fetchSDKDSRList } from '@/lib/sdk/dsr/api'
import { DSRWorkflowStepperCompact } from '@/components/sdk/dsr'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'intake' | 'processing' | 'completed' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
<span className="flex items-center gap-2">
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
<span className={`
px-2 py-0.5 text-xs rounded-full
${tab.countColor || 'bg-gray-100 text-gray-600'}
`}>
{tab.count}
</span>
)}
</span>
</button>
))}
</nav>
</div>
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
{label}
</div>
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
{value}
</div>
{trend && (
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
</div>
)}
</div>
{icon && (
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
{icon}
</div>
)}
</div>
</div>
)
}
function RequestCard({ request }: { request: DSRRequest }) {
const typeInfo = DSR_TYPE_INFO[request.type]
const statusInfo = DSR_STATUS_INFO[request.status]
const daysRemaining = getDaysRemaining(request.deadline.currentDeadline)
const overdue = isOverdue(request)
const urgent = isUrgent(request)
return (
<Link href={`/sdk/dsr/${request.id}`}>
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
${overdue ? 'border-red-300 hover:border-red-400' :
urgent ? 'border-orange-300 hover:border-orange-400' :
request.status === 'completed' ? 'border-green-200 hover:border-green-300' :
'border-gray-200 hover:border-purple-300'
}
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="text-xs text-gray-500 font-mono">
{request.referenceNumber}
</span>
<span className={`px-2 py-1 text-xs rounded-full ${typeInfo.bgColor} ${typeInfo.color}`}>
{typeInfo.article} {typeInfo.labelShort}
</span>
{!request.identityVerification.verified && request.status !== 'completed' && request.status !== 'rejected' && (
<span className="px-2 py-1 text-xs bg-yellow-100 text-yellow-700 rounded-full flex items-center gap-1">
<svg className="w-3 h-3" 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>
ID fehlt
</span>
)}
</div>
{/* Requester Info */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{request.requester.name}
</h3>
<p className="text-sm text-gray-500 truncate">{request.requester.email}</p>
{/* Workflow Status */}
<div className="mt-3">
<DSRWorkflowStepperCompact currentStatus={request.status} />
</div>
</div>
{/* Right Side - Deadline */}
<div className={`text-right ml-4 ${
overdue ? 'text-red-600' :
urgent ? 'text-orange-600' :
'text-gray-500'
}`}>
<div className="text-sm font-medium">
{request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
? statusInfo.label
: overdue
? `${Math.abs(daysRemaining)} Tage ueberfaellig`
: `${daysRemaining} Tage`
}
</div>
<div className="text-xs mt-0.5">
{new Date(request.receivedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Notes Preview */}
{request.notes && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg text-sm text-gray-600 line-clamp-2">
{request.notes}
</div>
)}
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
{request.assignment.assignedTo
? `Zugewiesen: ${request.assignment.assignedTo}`
: 'Nicht zugewiesen'
}
</div>
<div className="flex items-center gap-2">
{request.status !== 'completed' && request.status !== 'rejected' && request.status !== 'cancelled' && (
<>
{!request.identityVerification.verified && (
<span className="px-3 py-1 text-sm bg-yellow-50 text-yellow-700 rounded-lg">
ID pruefen
</span>
)}
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Bearbeiten
</span>
</>
)}
{request.status === 'completed' && (
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Details
</span>
)}
</div>
</div>
</div>
</Link>
)
}
function FilterBar({
selectedType,
selectedStatus,
selectedPriority,
onTypeChange,
onStatusChange,
onPriorityChange,
onClear
}: {
selectedType: DSRType | 'all'
selectedStatus: DSRStatus | 'all'
selectedPriority: string
onTypeChange: (type: DSRType | 'all') => void
onStatusChange: (status: DSRStatus | 'all') => void
onPriorityChange: (priority: string) => void
onClear: () => void
}) {
const hasFilters = selectedType !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
return (
<div className="flex items-center gap-4 flex-wrap">
<span className="text-sm text-gray-500">Filter:</span>
{/* Type Filter */}
<select
value={selectedType}
onChange={(e) => onTypeChange(e.target.value as DSRType | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Typen</option>
{Object.entries(DSR_TYPE_INFO).map(([type, info]) => (
<option key={type} value={type}>{info.article} - {info.labelShort}</option>
))}
</select>
{/* Status Filter */}
<select
value={selectedStatus}
onChange={(e) => onStatusChange(e.target.value as DSRStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Status</option>
{Object.entries(DSR_STATUS_INFO).map(([status, info]) => (
<option key={status} value={status}>{info.label}</option>
))}
</select>
{/* Priority Filter */}
<select
value={selectedPriority}
onChange={(e) => onPriorityChange(e.target.value)}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="all">Alle Prioritaeten</option>
<option value="critical">Kritisch</option>
<option value="high">Hoch</option>
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
{/* Clear Filters */}
{hasFilters && (
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
)}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function DSRPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [requests, setRequests] = useState<DSRRequest[]>([])
const [statistics, setStatistics] = useState<DSRStatistics | null>(null)
const [isLoading, setIsLoading] = useState(true)
// Filters
const [selectedType, setSelectedType] = useState<DSRType | 'all'>('all')
const [selectedStatus, setSelectedStatus] = useState<DSRStatus | 'all'>('all')
const [selectedPriority, setSelectedPriority] = useState<string>('all')
// Load data from SDK backend
useEffect(() => {
const loadData = async () => {
setIsLoading(true)
try {
const { requests: dsrRequests, statistics: dsrStats } = await fetchSDKDSRList()
setRequests(dsrRequests)
setStatistics(dsrStats)
} catch (error) {
console.error('Failed to load DSR data:', error)
} finally {
setIsLoading(false)
}
}
loadData()
}, [])
// Calculate tab counts
const tabCounts = useMemo(() => {
return {
intake: requests.filter(r => r.status === 'intake' || r.status === 'identity_verification').length,
processing: requests.filter(r => r.status === 'processing').length,
completed: requests.filter(r => r.status === 'completed' || r.status === 'rejected' || r.status === 'cancelled').length,
overdue: requests.filter(r => isOverdue(r)).length
}
}, [requests])
// Filter requests based on active tab and filters
const filteredRequests = useMemo(() => {
let filtered = [...requests]
// Tab-based filtering
if (activeTab === 'intake') {
filtered = filtered.filter(r => r.status === 'intake' || r.status === 'identity_verification')
} else if (activeTab === 'processing') {
filtered = filtered.filter(r => r.status === 'processing')
} else if (activeTab === 'completed') {
filtered = filtered.filter(r => r.status === 'completed' || r.status === 'rejected' || r.status === 'cancelled')
}
// Type filter
if (selectedType !== 'all') {
filtered = filtered.filter(r => r.type === selectedType)
}
// Status filter
if (selectedStatus !== 'all') {
filtered = filtered.filter(r => r.status === selectedStatus)
}
// Priority filter
if (selectedPriority !== 'all') {
filtered = filtered.filter(r => r.priority === selectedPriority)
}
// Sort by urgency
return filtered.sort((a, b) => {
const getUrgency = (r: DSRRequest) => {
if (r.status === 'completed' || r.status === 'rejected' || r.status === 'cancelled') return 100
const days = getDaysRemaining(r.deadline.currentDeadline)
if (days < 0) return -100 + days // Overdue items first
return days
}
return getUrgency(a) - getUrgency(b)
})
}, [requests, activeTab, selectedType, selectedStatus, selectedPriority])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'intake', label: 'Eingang', count: tabCounts.intake, countColor: 'bg-blue-100 text-blue-600' },
{ id: 'processing', label: 'In Bearbeitung', count: tabCounts.processing, countColor: 'bg-yellow-100 text-yellow-600' },
{ id: 'completed', label: 'Abgeschlossen', count: tabCounts.completed, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['dsr']
const clearFilters = () => {
setSelectedType('all')
setSelectedStatus('all')
setSelectedPriority('all')
}
return (
<div className="space-y-6">
{/* Step Header */}
<StepHeader
stepId="dsr"
title={stepInfo.title}
description={stepInfo.description}
explanation={stepInfo.explanation}
tips={stepInfo.tips}
>
<Link
href="/sdk/dsr/new"
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Anfrage erfassen
</Link>
</StepHeader>
{/* Tab Navigation */}
<TabNavigation
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
/>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<svg className="animate-spin w-8 h-8 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>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
DSR-Portal-Einstellungen, E-Mail-Vorlagen und Workflow-Konfiguration
werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
label="Gesamt"
value={statistics.total}
color="gray"
/>
<StatCard
label="Neue Anfragen"
value={statistics.byStatus.intake + statistics.byStatus.identity_verification}
color="blue"
/>
<StatCard
label="In Bearbeitung"
value={statistics.byStatus.processing}
color="yellow"
/>
<StatCard
label="Ueberfaellig"
value={tabCounts.overdue}
color={tabCounts.overdue > 0 ? 'red' : 'green'}
/>
</div>
)}
{/* Overdue Alert */}
{tabCounts.overdue > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
<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>
<div className="flex-1">
<h4 className="font-medium text-red-800">
Achtung: {tabCounts.overdue} ueberfaellige Anfrage(n)
</h4>
<p className="text-sm text-red-600">
Die gesetzliche Frist ist abgelaufen. Handeln Sie umgehend, um Bussgelder zu vermeiden.
</p>
</div>
<button
onClick={() => {
setActiveTab('overview')
setSelectedStatus('all')
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
</button>
</div>
)}
{/* Info Box (Overview Tab) */}
{activeTab === 'overview' && (
<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>
<h4 className="font-medium text-blue-800">Fristen beachten</h4>
<p className="text-sm text-blue-600 mt-1">
Nach Art. 12 DSGVO muessen Anfragen innerhalb von einem Monat beantwortet werden.
Eine Verlaengerung um zwei weitere Monate ist bei komplexen Anfragen moeglich,
sofern der Betroffene innerhalb eines Monats darueber informiert wird.
</p>
</div>
</div>
</div>
)}
{/* Filters */}
<FilterBar
selectedType={selectedType}
selectedStatus={selectedStatus}
selectedPriority={selectedPriority}
onTypeChange={setSelectedType}
onStatusChange={setSelectedStatus}
onPriorityChange={setSelectedPriority}
onClear={clearFilters}
/>
{/* Requests List */}
<div className="space-y-4">
{filteredRequests.map(request => (
<RequestCard key={request.id} request={request} />
))}
</div>
{/* Empty State */}
{filteredRequests.length === 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Keine Anfragen gefunden</h3>
<p className="mt-2 text-gray-500">
{selectedType !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
? 'Passen Sie die Filter an oder'
: 'Es sind noch keine Anfragen vorhanden.'
}
</p>
{(selectedType !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all') ? (
<button
onClick={clearFilters}
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
>
Filter zuruecksetzen
</button>
) : (
<Link
href="/sdk/dsr/new"
className="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Erste Anfrage erfassen
</Link>
)}
</div>
)}
</>
)}
</div>
)
}