refactor(admin): split dsr page.tsx into colocated components
Extract TabNavigation, StatCard, RequestCard, FilterBar, DSRCreateModal, DSRDetailPanel, DSRHeaderActions, and banner components (LoadingSpinner, SettingsTab, OverdueAlert, DeadlineInfoBox, EmptyState) into _components/ so page.tsx drops from 1019 to 247 LOC (under the 300 soft target). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
136
admin-compliance/app/sdk/dsr/_components/DSRBanners.tsx
Normal file
136
admin-compliance/app/sdk/dsr/_components/DSRBanners.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import type { DSRType, DSRStatus } from '@/lib/sdk/dsr/types'
|
||||
|
||||
export function LoadingSpinner() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsTab() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverdueAlert({
|
||||
overdueCount,
|
||||
onShowOverdue,
|
||||
}: {
|
||||
overdueCount: number
|
||||
onShowOverdue: () => void
|
||||
}) {
|
||||
return (
|
||||
<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: {overdueCount} 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={onShowOverdue}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export function DeadlineInfoBox() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
selectedType,
|
||||
selectedStatus,
|
||||
selectedPriority,
|
||||
onClearFilters,
|
||||
onOpenCreate,
|
||||
}: {
|
||||
selectedType: DSRType | 'all'
|
||||
selectedStatus: DSRStatus | 'all'
|
||||
selectedPriority: string
|
||||
onClearFilters: () => void
|
||||
onOpenCreate: () => void
|
||||
}) {
|
||||
const hasFilters =
|
||||
selectedType !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
|
||||
|
||||
return (
|
||||
<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">
|
||||
{hasFilters
|
||||
? 'Passen Sie die Filter an oder'
|
||||
: 'Es sind noch keine Anfragen vorhanden.'
|
||||
}
|
||||
</p>
|
||||
{hasFilters ? (
|
||||
<button
|
||||
onClick={onClearFilters}
|
||||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onOpenCreate}
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
187
admin-compliance/app/sdk/dsr/_components/DSRCreateModal.tsx
Normal file
187
admin-compliance/app/sdk/dsr/_components/DSRCreateModal.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { createSDKDSR } from '@/lib/sdk/dsr/api'
|
||||
|
||||
export function DSRCreateModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [type, setType] = useState<string>('access')
|
||||
const [subjectName, setSubjectName] = useState('')
|
||||
const [subjectEmail, setSubjectEmail] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [source, setSource] = useState<string>('web_form')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const deadline = new Date()
|
||||
deadline.setDate(deadline.getDate() + 30)
|
||||
const deadlineStr = deadline.toLocaleDateString('de-DE')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!subjectName.trim() || !subjectEmail.trim()) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await createSDKDSR({
|
||||
type,
|
||||
requester: { name: subjectName.trim(), email: subjectEmail.trim() },
|
||||
requestText: description.trim(),
|
||||
source
|
||||
})
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Neue Anfrage anlegen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Art der Anfrage
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="access">Art. 15 - Auskunft</option>
|
||||
<option value="rectification">Art. 16 - Berichtigung</option>
|
||||
<option value="erasure">Art. 17 - Loeschung</option>
|
||||
<option value="restriction">Art. 18 - Einschraenkung</option>
|
||||
<option value="portability">Art. 20 - Datenpортabilitaet</option>
|
||||
<option value="objection">Art. 21 - Widerspruch</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Subject Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name der betroffenen Person <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectName}
|
||||
onChange={(e) => setSubjectName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Vor- und Nachname"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subject Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
E-Mail der betroffenen Person <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={subjectEmail}
|
||||
onChange={(e) => setSubjectEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="email@beispiel.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm resize-none"
|
||||
placeholder="Details zur Anfrage..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Eingangskanal
|
||||
</label>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="web_form">Webformular</option>
|
||||
<option value="email">E-Mail</option>
|
||||
<option value="phone">Telefon</option>
|
||||
<option value="letter">Brief</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Deadline Info */}
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-700">
|
||||
Gesetzliche Frist (Art. 12 DSGVO): Die Anfrage muss bis zum{' '}
|
||||
<strong>{deadlineStr}</strong> beantwortet werden (30 Tage ab heute).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving || !subjectName.trim() || !subjectEmail.trim()}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? 'Wird angelegt...' : 'Anfrage anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
211
admin-compliance/app/sdk/dsr/_components/DSRDetailPanel.tsx
Normal file
211
admin-compliance/app/sdk/dsr/_components/DSRDetailPanel.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
DSRRequest,
|
||||
DSRStatus,
|
||||
DSR_TYPE_INFO,
|
||||
DSR_STATUS_INFO,
|
||||
getDaysRemaining,
|
||||
isOverdue,
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
import { updateSDKDSRStatus } from '@/lib/sdk/dsr/api'
|
||||
import { DSRWorkflowStepperCompact } from '@/components/sdk/dsr'
|
||||
|
||||
export function DSRDetailPanel({
|
||||
request,
|
||||
onClose,
|
||||
onUpdated
|
||||
}: {
|
||||
request: DSRRequest
|
||||
onClose: () => void
|
||||
onUpdated: () => void
|
||||
}) {
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
const typeInfo = DSR_TYPE_INFO[request.type]
|
||||
const statusInfo = DSR_STATUS_INFO[request.status]
|
||||
const daysRemaining = getDaysRemaining(request.deadline.currentDeadline)
|
||||
const overdue = isOverdue(request)
|
||||
|
||||
type StatusTransition = { label: string; next: DSRStatus; variant?: 'danger' }
|
||||
const statusTransitions: Partial<Record<DSRStatus, StatusTransition[]>> = {
|
||||
intake: [{ label: 'Identitaet pruefen', next: 'identity_verification' }],
|
||||
identity_verification: [{ label: 'Bearbeitung starten', next: 'processing' }],
|
||||
processing: [
|
||||
{ label: 'Anfrage abschliessen', next: 'completed' },
|
||||
{ label: 'Ablehnen', next: 'rejected', variant: 'danger' }
|
||||
]
|
||||
}
|
||||
|
||||
const transitions = statusTransitions[request.status] || []
|
||||
|
||||
const handleStatusChange = async (newStatus: DSRStatus) => {
|
||||
setIsUpdatingStatus(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
await updateSDKDSRStatus(request.id, newStatus)
|
||||
onUpdated()
|
||||
} catch (err: unknown) {
|
||||
setActionError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsUpdatingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPDF = () => {
|
||||
window.open(`/api/sdk/v1/compliance/dsr/${request.id}/export`, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 bottom-0 z-50 w-[600px] bg-white shadow-2xl overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-mono">{request.referenceNumber}</p>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-0.5">
|
||||
{typeInfo.article} {typeInfo.labelShort}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExportPDF}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
PDF
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{actionError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Stepper */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Bearbeitungsstand</h3>
|
||||
<DSRWorkflowStepperCompact currentStatus={request.status} />
|
||||
</div>
|
||||
|
||||
{/* Requester Info */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Antragsteller</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center text-purple-600 font-medium text-sm">
|
||||
{request.requester.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{request.requester.name}</p>
|
||||
<p className="text-xs text-gray-500">{request.requester.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deadline */}
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
overdue ? 'bg-red-50 border-red-200' : 'bg-gray-50 border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Gesetzliche Frist</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(request.deadline.currentDeadline).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`text-right ${overdue ? 'text-red-600' : daysRemaining <= 7 ? 'text-orange-600' : 'text-gray-600'}`}>
|
||||
<p className="text-lg font-bold">
|
||||
{overdue ? `${Math.abs(daysRemaining)}` : daysRemaining}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{overdue ? 'Tage ueberfaellig' : 'Tage verbleibend'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Status</p>
|
||||
<p className="text-sm font-medium text-gray-900">{statusInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Zugewiesen an</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{request.assignment?.assignedTo || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Eingegangen am</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(request.receivedAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Identitaet geprueft</p>
|
||||
<p className={`text-sm font-medium ${request.identityVerification.verified ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||
{request.identityVerification.verified ? 'Ja' : 'Ausstehend'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{request.notes && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Notizen</h3>
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{request.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Transitions */}
|
||||
{transitions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Naechste Schritte</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{transitions.map((t) => (
|
||||
<button
|
||||
key={t.next}
|
||||
onClick={() => handleStatusChange(t.next)}
|
||||
disabled={isUpdatingStatus}
|
||||
className={`px-4 py-2 text-sm rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
||||
t.variant === 'danger'
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{isUpdatingStatus ? 'Wird gespeichert...' : t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
export function DSRHeaderActions({
|
||||
onOpenCreate,
|
||||
}: {
|
||||
onOpenCreate: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const link = document.createElement('a')
|
||||
link.href = '/api/sdk/v1/compliance/dsr/export?format=csv'
|
||||
link.download = 'dsr_export.csv'
|
||||
link.click()
|
||||
}}
|
||||
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-5 h-5" 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>
|
||||
CSV Export
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenCreate}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
admin-compliance/app/sdk/dsr/_components/FilterBar.tsx
Normal file
81
admin-compliance/app/sdk/dsr/_components/FilterBar.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
DSRType,
|
||||
DSRStatus,
|
||||
DSR_TYPE_INFO,
|
||||
DSR_STATUS_INFO,
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
121
admin-compliance/app/sdk/dsr/_components/RequestCard.tsx
Normal file
121
admin-compliance/app/sdk/dsr/_components/RequestCard.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
DSRRequest,
|
||||
DSR_TYPE_INFO,
|
||||
DSR_STATUS_INFO,
|
||||
getDaysRemaining,
|
||||
isOverdue,
|
||||
isUrgent
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
import { DSRWorkflowStepperCompact } from '@/components/sdk/dsr'
|
||||
|
||||
export function RequestCard({ request, onClick }: { request: DSRRequest; onClick?: () => void }) {
|
||||
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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
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>
|
||||
)
|
||||
}
|
||||
51
admin-compliance/app/sdk/dsr/_components/StatCard.tsx
Normal file
51
admin-compliance/app/sdk/dsr/_components/StatCard.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
45
admin-compliance/app/sdk/dsr/_components/TabNavigation.tsx
Normal file
45
admin-compliance/app/sdk/dsr/_components/TabNavigation.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import type { Tab, TabId } from '../_types'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
8
admin-compliance/app/sdk/dsr/_types.ts
Normal file
8
admin-compliance/app/sdk/dsr/_types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type TabId = 'overview' | 'intake' | 'processing' | 'completed' | 'settings'
|
||||
|
||||
export interface Tab {
|
||||
id: TabId
|
||||
label: string
|
||||
count?: number
|
||||
countColor?: string
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import {
|
||||
@@ -8,693 +8,25 @@ import {
|
||||
DSRType,
|
||||
DSRStatus,
|
||||
DSRStatistics,
|
||||
DSR_TYPE_INFO,
|
||||
DSR_STATUS_INFO,
|
||||
getDaysRemaining,
|
||||
isOverdue,
|
||||
isUrgent
|
||||
} from '@/lib/sdk/dsr/types'
|
||||
import { fetchSDKDSRList, createSDKDSR, updateSDKDSRStatus } 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, onClick }: { request: DSRRequest; onClick?: () => void }) {
|
||||
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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DSR CREATE MODAL
|
||||
// =============================================================================
|
||||
|
||||
function DSRCreateModal({
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [type, setType] = useState<string>('access')
|
||||
const [subjectName, setSubjectName] = useState('')
|
||||
const [subjectEmail, setSubjectEmail] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [source, setSource] = useState<string>('web_form')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const deadline = new Date()
|
||||
deadline.setDate(deadline.getDate() + 30)
|
||||
const deadlineStr = deadline.toLocaleDateString('de-DE')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!subjectName.trim() || !subjectEmail.trim()) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await createSDKDSR({
|
||||
type,
|
||||
requester: { name: subjectName.trim(), email: subjectEmail.trim() },
|
||||
requestText: description.trim(),
|
||||
source
|
||||
})
|
||||
onSuccess()
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Neue Anfrage anlegen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Art der Anfrage
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="access">Art. 15 - Auskunft</option>
|
||||
<option value="rectification">Art. 16 - Berichtigung</option>
|
||||
<option value="erasure">Art. 17 - Loeschung</option>
|
||||
<option value="restriction">Art. 18 - Einschraenkung</option>
|
||||
<option value="portability">Art. 20 - Datenpортabilitaet</option>
|
||||
<option value="objection">Art. 21 - Widerspruch</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Subject Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name der betroffenen Person <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subjectName}
|
||||
onChange={(e) => setSubjectName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="Vor- und Nachname"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subject Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
E-Mail der betroffenen Person <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={subjectEmail}
|
||||
onChange={(e) => setSubjectEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
placeholder="email@beispiel.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm resize-none"
|
||||
placeholder="Details zur Anfrage..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Eingangskanal
|
||||
</label>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
||||
>
|
||||
<option value="web_form">Webformular</option>
|
||||
<option value="email">E-Mail</option>
|
||||
<option value="phone">Telefon</option>
|
||||
<option value="letter">Brief</option>
|
||||
<option value="other">Sonstiges</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Deadline Info */}
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-sm text-blue-700">
|
||||
Gesetzliche Frist (Art. 12 DSGVO): Die Anfrage muss bis zum{' '}
|
||||
<strong>{deadlineStr}</strong> beantwortet werden (30 Tage ab heute).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving || !subjectName.trim() || !subjectEmail.trim()}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSaving ? 'Wird angelegt...' : 'Anfrage anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// DSR DETAIL PANEL
|
||||
// =============================================================================
|
||||
|
||||
function DSRDetailPanel({
|
||||
request,
|
||||
onClose,
|
||||
onUpdated
|
||||
}: {
|
||||
request: DSRRequest
|
||||
onClose: () => void
|
||||
onUpdated: () => void
|
||||
}) {
|
||||
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
const typeInfo = DSR_TYPE_INFO[request.type]
|
||||
const statusInfo = DSR_STATUS_INFO[request.status]
|
||||
const daysRemaining = getDaysRemaining(request.deadline.currentDeadline)
|
||||
const overdue = isOverdue(request)
|
||||
|
||||
type StatusTransition = { label: string; next: DSRStatus; variant?: 'danger' }
|
||||
const statusTransitions: Partial<Record<DSRStatus, StatusTransition[]>> = {
|
||||
intake: [{ label: 'Identitaet pruefen', next: 'identity_verification' }],
|
||||
identity_verification: [{ label: 'Bearbeitung starten', next: 'processing' }],
|
||||
processing: [
|
||||
{ label: 'Anfrage abschliessen', next: 'completed' },
|
||||
{ label: 'Ablehnen', next: 'rejected', variant: 'danger' }
|
||||
]
|
||||
}
|
||||
|
||||
const transitions = statusTransitions[request.status] || []
|
||||
|
||||
const handleStatusChange = async (newStatus: DSRStatus) => {
|
||||
setIsUpdatingStatus(true)
|
||||
setActionError(null)
|
||||
try {
|
||||
await updateSDKDSRStatus(request.id, newStatus)
|
||||
onUpdated()
|
||||
} catch (err: unknown) {
|
||||
setActionError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsUpdatingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportPDF = () => {
|
||||
window.open(`/api/sdk/v1/compliance/dsr/${request.id}/export`, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/30"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 bottom-0 z-50 w-[600px] bg-white shadow-2xl overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 font-mono">{request.referenceNumber}</p>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-0.5">
|
||||
{typeInfo.article} {typeInfo.labelShort}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleExportPDF}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
PDF
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{actionError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Stepper */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Bearbeitungsstand</h3>
|
||||
<DSRWorkflowStepperCompact currentStatus={request.status} />
|
||||
</div>
|
||||
|
||||
{/* Requester Info */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Antragsteller</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center text-purple-600 font-medium text-sm">
|
||||
{request.requester.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{request.requester.name}</p>
|
||||
<p className="text-xs text-gray-500">{request.requester.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deadline */}
|
||||
<div className={`p-4 rounded-lg border ${
|
||||
overdue ? 'bg-red-50 border-red-200' : 'bg-gray-50 border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Gesetzliche Frist</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(request.deadline.currentDeadline).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`text-right ${overdue ? 'text-red-600' : daysRemaining <= 7 ? 'text-orange-600' : 'text-gray-600'}`}>
|
||||
<p className="text-lg font-bold">
|
||||
{overdue ? `${Math.abs(daysRemaining)}` : daysRemaining}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{overdue ? 'Tage ueberfaellig' : 'Tage verbleibend'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Status</p>
|
||||
<p className="text-sm font-medium text-gray-900">{statusInfo.label}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Zugewiesen an</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{request.assignment?.assignedTo || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Eingegangen am</p>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(request.receivedAt).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Identitaet geprueft</p>
|
||||
<p className={`text-sm font-medium ${request.identityVerification.verified ? 'text-green-600' : 'text-yellow-600'}`}>
|
||||
{request.identityVerification.verified ? 'Ja' : 'Ausstehend'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{request.notes && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Notizen</h3>
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{request.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Transitions */}
|
||||
{transitions.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Naechste Schritte</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{transitions.map((t) => (
|
||||
<button
|
||||
key={t.next}
|
||||
onClick={() => handleStatusChange(t.next)}
|
||||
disabled={isUpdatingStatus}
|
||||
className={`px-4 py-2 text-sm rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
|
||||
t.variant === 'danger'
|
||||
? 'bg-red-600 text-white hover:bg-red-700'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{isUpdatingStatus ? 'Wird gespeichert...' : t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { fetchSDKDSRList } from '@/lib/sdk/dsr/api'
|
||||
import type { Tab, TabId } from './_types'
|
||||
import { TabNavigation } from './_components/TabNavigation'
|
||||
import { StatCard } from './_components/StatCard'
|
||||
import { RequestCard } from './_components/RequestCard'
|
||||
import { FilterBar } from './_components/FilterBar'
|
||||
import { DSRCreateModal } from './_components/DSRCreateModal'
|
||||
import { DSRDetailPanel } from './_components/DSRDetailPanel'
|
||||
import { DSRHeaderActions } from './_components/DSRHeaderActions'
|
||||
import {
|
||||
LoadingSpinner,
|
||||
SettingsTab,
|
||||
OverdueAlert,
|
||||
DeadlineInfoBox,
|
||||
EmptyState,
|
||||
} from './_components/DSRBanners'
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
@@ -808,31 +140,7 @@ export default function DSRPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const link = document.createElement('a')
|
||||
link.href = '/api/sdk/v1/compliance/dsr/export?format=csv'
|
||||
link.download = 'dsr_export.csv'
|
||||
link.click()
|
||||
}}
|
||||
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-5 h-5" 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>
|
||||
CSV Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
<DSRHeaderActions onOpenCreate={() => setShowCreateModal(true)} />
|
||||
</StepHeader>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
@@ -844,37 +152,15 @@ export default function DSRPage() {
|
||||
|
||||
{/* 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>
|
||||
<LoadingSpinner />
|
||||
) : 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>
|
||||
<SettingsTab />
|
||||
) : (
|
||||
<>
|
||||
{/* 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="Gesamt" value={statistics.total} color="gray" />
|
||||
<StatCard
|
||||
label="Neue Anfragen"
|
||||
value={statistics.byStatus.intake + statistics.byStatus.identity_verification}
|
||||
@@ -895,50 +181,17 @@ export default function DSRPage() {
|
||||
|
||||
{/* 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>
|
||||
<OverdueAlert
|
||||
overdueCount={tabCounts.overdue}
|
||||
onShowOverdue={() => {
|
||||
setActiveTab('overview')
|
||||
setSelectedStatus('all')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{activeTab === 'overview' && <DeadlineInfoBox />}
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar
|
||||
@@ -964,38 +217,13 @@ export default function DSRPage() {
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
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
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<EmptyState
|
||||
selectedType={selectedType}
|
||||
selectedStatus={selectedStatus}
|
||||
selectedPriority={selectedPriority}
|
||||
onClearFilters={clearFilters}
|
||||
onOpenCreate={() => setShowCreateModal(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user