refactor(admin): split dsr/new, compliance-hub, iace/monitoring, cookie-banner pages

Extract components and hooks from 4 oversized pages (518–508 LOC each) to bring
each page.tsx under 300 LOC (hard cap 500). Zero behavior changes.

- dsr/new: TypeSelector, SourceSelector → _components/; useNewDSRForm → _hooks/
- compliance-hub: QuickActions, StatsRow, DomainChart, MappingsAndFindings,
  RegulationsTable → _components/; useComplianceHub → _hooks/
- iace/[projectId]/monitoring: Badges, EventForm, ResolveModal, TimelineEvent →
  _components/; useMonitoring → _hooks/
- cookie-banner: BannerPreview, CategoryCard → _components/; useCookieBanner → _hooks/

Result: page.tsx LOC: dsr/new=259, compliance-hub=95, monitoring=157, cookie-banner=212

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-16 13:22:01 +02:00
parent 519ffdc8dc
commit e04816cfe5
20 changed files with 1514 additions and 1378 deletions

View File

@@ -0,0 +1,74 @@
'use client'
import { DSRSource } from '@/lib/sdk/dsr/types'
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' }
]
export function SourceSelector({
selectedSource,
sourceDetails,
onSourceChange,
onDetailsChange
}: {
selectedSource: DSRSource | ''
sourceDetails: string
onSourceChange: (source: DSRSource) => void
onDetailsChange: (details: string) => void
}) {
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>
)
}

View File

@@ -0,0 +1,70 @@
'use client'
import { DSRType, DSR_TYPE_INFO } from '@/lib/sdk/dsr/types'
export 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>
)
}

View File

@@ -0,0 +1,111 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import {
DSRType,
DSRSource,
DSRPriority,
DSRCreateRequest
} from '@/lib/sdk/dsr/types'
import { createSDKDSR } from '@/lib/sdk/dsr/api'
export interface FormData {
type: DSRType | ''
requesterName: string
requesterEmail: string
requesterPhone: string
requesterAddress: string
source: DSRSource | ''
sourceDetails: string
requestText: string
priority: DSRPriority
customerId: string
}
export function useNewDSRForm() {
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 }))
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 {
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)
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 { formData, errors, isSubmitting, updateField, handleSubmit }
}

View File

@@ -1,266 +1,14 @@
'use client'
import React, { useState } from 'react'
import React 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
// =============================================================================
import { DSRType, DSRPriority } from '@/lib/sdk/dsr/types'
import { TypeSelector } from './_components/TypeSelector'
import { SourceSelector } from './_components/SourceSelector'
import { useNewDSRForm } from './_hooks/useNewDSRForm'
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)
}
}
const { formData, errors, isSubmitting, updateField, handleSubmit } = useNewDSRForm()
return (
<div className="space-y-6">
@@ -300,7 +48,6 @@ export default function NewDSRPage() {
<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>
@@ -320,7 +67,6 @@ export default function NewDSRPage() {
)}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
E-Mail <span className="text-red-500">*</span>
@@ -340,7 +86,6 @@ export default function NewDSRPage() {
)}
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Telefon (optional)
@@ -354,7 +99,6 @@ export default function NewDSRPage() {
/>
</div>
{/* Customer ID */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kunden-ID (optional)
@@ -369,7 +113,6 @@ export default function NewDSRPage() {
</div>
</div>
{/* Address */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Adresse (optional)
@@ -398,7 +141,6 @@ export default function NewDSRPage() {
<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)
@@ -415,7 +157,6 @@ export default function NewDSRPage() {
</p>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Prioritaet