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:
749
admin-compliance/app/(sdk)/sdk/dsr/[requestId]/page.tsx
Normal file
749
admin-compliance/app/(sdk)/sdk/dsr/[requestId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
518
admin-compliance/app/(sdk)/sdk/dsr/new/page.tsx
Normal file
518
admin-compliance/app/(sdk)/sdk/dsr/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
592
admin-compliance/app/(sdk)/sdk/dsr/page.tsx
Normal file
592
admin-compliance/app/(sdk)/sdk/dsr/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user