- /sdk/consent: Replace hardcoded mockDocuments with GET /api/admin/consent/documents - /sdk/dsr: Replace createMockDSRList with fetchSDKDSRList via /api/sdk/v1/dsgvo/dsr - /sdk/dsr/new: Replace console.log mock with real POST to create DSR requests - /sdk/dsr/[requestId]: Replace mock lookup with real GET/PUT for DSR details and status updates - /sdk/consent-management: Add real stats, GDPR process counts, and email template editor - lib/sdk/dsr/api.ts: Add transformBackendDSR adapter (flat backend → nested frontend types) Prepares for removal of /dsgvo and /compliance pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
519 lines
20 KiB
TypeScript
519 lines
20 KiB
TypeScript
'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>
|
|
)
|
|
}
|