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,91 @@
'use client'
import Link from 'next/link'
import { DashboardData, MappingsData, FindingsData } from '../_hooks/useComplianceHub'
export function MappingsAndFindings({
dashboard,
mappings,
findings,
}: {
dashboard: DashboardData | null
mappings: MappingsData | null
findings: FindingsData | null
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Control-Mappings</h3>
<Link href="/sdk/controls" className="text-sm text-purple-600 hover:text-purple-700">
Alle anzeigen
</Link>
</div>
<div className="flex items-center gap-6 mb-4">
<div>
<p className="text-4xl font-bold text-purple-600">{mappings?.total || 0}</p>
<p className="text-sm text-slate-500">Mappings gesamt</p>
</div>
<div className="flex-1 h-16 bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500 mb-1">Nach Verordnung</p>
<div className="flex gap-1 flex-wrap">
{mappings?.by_regulation && Object.entries(mappings.by_regulation).slice(0, 5).map(([reg, count]) => (
<span key={reg} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{reg}: {count}
</span>
))}
{!mappings?.by_regulation && (
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-xs">Keine Mappings vorhanden</span>
)}
</div>
</div>
</div>
<p className="text-sm text-slate-600">
Automatisch generierte Verknuepfungen zwischen {dashboard?.total_controls || 0} Controls
und {dashboard?.total_requirements || 0} Anforderungen aus {dashboard?.total_regulations || 0} Verordnungen.
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Audit Findings</h3>
<Link href="/sdk/audit-checklist" className="text-sm text-purple-600 hover:text-purple-700">
Audit Checkliste
</Link>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-sm font-medium text-red-800">Hauptabweichungen</span>
</div>
<p className="text-3xl font-bold text-red-600">{findings?.open_majors || 0}</p>
<p className="text-xs text-red-600">offen (blockiert Zertifizierung)</p>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
<span className="text-sm font-medium text-yellow-800">Nebenabweichungen</span>
</div>
<p className="text-3xl font-bold text-yellow-600">{findings?.open_minors || 0}</p>
<p className="text-xs text-yellow-600">offen (erfordert CAPA)</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">
Gesamt: {findings?.total || 0} Findings ({findings?.major_count || 0} Major, {findings?.minor_count || 0} Minor, {findings?.ofi_count || 0} OFI)
</span>
{(findings?.open_majors || 0) === 0 ? (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">
Zertifizierung moeglich
</span>
) : (
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
Zertifizierung blockiert
</span>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import Link from 'next/link'
import { DashboardData } from '../_hooks/useComplianceHub'
export function QuickActions({ dashboard }: { dashboard: DashboardData | null }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
<Link
href="/sdk/audit-checklist"
className="p-4 rounded-lg border border-slate-200 hover:border-purple-500 hover:bg-purple-50 transition-colors text-center"
>
<div className="text-purple-600 mb-2 flex justify-center">
<svg className="w-8 h-8" 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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Audit Checkliste</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_requirements || '...'} Anforderungen</p>
</Link>
<Link
href="/sdk/controls"
className="p-4 rounded-lg border border-slate-200 hover:border-green-500 hover:bg-green-50 transition-colors text-center"
>
<div className="text-green-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Controls</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_controls || '...'} Massnahmen</p>
</Link>
<Link
href="/sdk/evidence"
className="p-4 rounded-lg border border-slate-200 hover:border-blue-500 hover:bg-blue-50 transition-colors text-center"
>
<div className="text-blue-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Evidence</p>
<p className="text-xs text-slate-500 mt-1">Nachweise</p>
</Link>
<Link
href="/sdk/risks"
className="p-4 rounded-lg border border-slate-200 hover:border-red-500 hover:bg-red-50 transition-colors text-center"
>
<div className="text-red-600 mb-2 flex justify-center">
<svg className="w-8 h-8" 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>
<p className="font-medium text-slate-900 text-sm">Risk Matrix</p>
<p className="text-xs text-slate-500 mt-1">5x5 Risiken</p>
</Link>
<Link
href="/sdk/modules"
className="p-4 rounded-lg border border-slate-200 hover:border-pink-500 hover:bg-pink-50 transition-colors text-center"
>
<div className="text-pink-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Service Registry</p>
<p className="text-xs text-slate-500 mt-1">Module</p>
</Link>
<Link
href="/sdk/audit-report"
className="p-4 rounded-lg border border-slate-200 hover:border-orange-500 hover:bg-orange-50 transition-colors text-center"
>
<div className="text-orange-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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>
</div>
<p className="font-medium text-slate-900 text-sm">Audit Report</p>
<p className="text-xs text-slate-500 mt-1">PDF Export</p>
</Link>
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
'use client'
import { Regulation } from '../_hooks/useComplianceHub'
export function RegulationsTable({
regulations,
onRefresh,
}: {
regulations: Regulation[]
onRefresh: () => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards ({regulations.length})</h3>
<button onClick={onRefresh} className="text-sm text-purple-600 hover:text-purple-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{regulations.slice(0, 15).map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-purple-600">{reg.code}</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{reg.name}</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-700'
}`}>
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
reg.regulation_type === 'bsi_standard' ? 'BSI' :
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-medium">{reg.requirement_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,142 @@
'use client'
import { DashboardData } from '../_hooks/useComplianceHub'
const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
export function StatsRow({
dashboard,
scoreColor,
scoreBgColor,
score,
}: {
dashboard: DashboardData | null
scoreColor: string
scoreBgColor: string
score: number
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
<div className={`text-5xl font-bold ${scoreColor}`}>
{score.toFixed(0)}%
</div>
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${scoreBgColor}`}
style={{ width: `${score}%` }}
/>
</div>
<p className="mt-2 text-sm text-slate-500">
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Verordnungen</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_regulations || 0}</p>
</div>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 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>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.total_requirements || 0} Anforderungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Controls</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_controls || 0}</p>
</div>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.controls_by_status?.pass || 0} bestanden</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Nachweise</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_evidence || 0}</p>
</div>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.evidence_by_status?.valid || 0} aktiv</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Risiken</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_risks || 0}</p>
</div>
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<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>
<p className="mt-2 text-sm text-slate-500">
{(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch
</p>
</div>
</div>
)
}
export function DomainChart({ dashboard }: { dashboard: DashboardData | null }) {
return (
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(dashboard?.controls_by_domain || {}).map(([domain, stats]) => {
const total = stats.total || 0
const pass = stats.pass || 0
const partial = stats.partial || 0
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
return (
<div key={domain} className="p-3 rounded-lg bg-slate-50">
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">
{DOMAIN_LABELS[domain] || domain.toUpperCase()}
</span>
<span className="text-slate-500">
{pass}/{total} ({passPercent.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${(pass / total) * 100}%` }} />
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / total) * 100}%` }} />
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,116 @@
'use client'
import { useState, useEffect } from 'react'
export interface DashboardData {
compliance_score: number
total_regulations: number
total_requirements: number
total_controls: number
controls_by_status: Record<string, number>
controls_by_domain: Record<string, Record<string, number>>
total_evidence: number
evidence_by_status: Record<string, number>
total_risks: number
risks_by_level: Record<string, number>
}
export interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
effective_date: string | null
description: string
requirement_count: number
}
export interface MappingsData {
total: number
by_regulation: Record<string, number>
}
export interface FindingsData {
major_count: number
minor_count: number
ofi_count: number
total: number
open_majors: number
open_minors: number
}
export function useComplianceHub() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [regulations, setRegulations] = useState<Regulation[]>([])
const [mappings, setMappings] = useState<MappingsData | null>(null)
const [findings, setFindings] = useState<FindingsData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [seeding, setSeeding] = useState(false)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
setError(null)
try {
const [dashboardRes, regulationsRes, mappingsRes, findingsRes] = await Promise.all([
fetch('/api/sdk/v1/compliance/dashboard'),
fetch('/api/sdk/v1/compliance/regulations'),
fetch('/api/sdk/v1/compliance/mappings'),
fetch('/api/sdk/v1/isms/findings?status=open'),
])
if (dashboardRes.ok) {
setDashboard(await dashboardRes.json())
}
if (regulationsRes.ok) {
const data = await regulationsRes.json()
setRegulations(data.regulations || [])
}
if (mappingsRes.ok) {
const data = await mappingsRes.json()
setMappings(data)
}
if (findingsRes.ok) {
const data = await findingsRes.json()
setFindings(data)
}
} catch (err) {
console.error('Failed to load compliance data:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}
const seedDatabase = async () => {
setSeeding(true)
try {
const res = await fetch('/api/sdk/v1/compliance/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: false }),
})
if (res.ok) {
const result = await res.json()
alert(`Datenbank erfolgreich initialisiert!\n\nRegulations: ${result.counts?.regulations || 0}\nControls: ${result.counts?.controls || 0}\nRequirements: ${result.counts?.requirements || 0}`)
loadData()
} else {
const errorText = await res.text()
alert(`Fehler beim Seeding: ${errorText}`)
}
} catch (err) {
console.error('Seeding failed:', err)
alert('Fehler beim Initialisieren der Datenbank')
} finally {
setSeeding(false)
}
}
return { dashboard, regulations, mappings, findings, loading, error, seeding, loadData, seedDatabase }
}

View File

@@ -11,131 +11,18 @@
* - Regulations overview
*/
import { useState, useEffect } from 'react'
import Link from 'next/link'
// Types
interface DashboardData {
compliance_score: number
total_regulations: number
total_requirements: number
total_controls: number
controls_by_status: Record<string, number>
controls_by_domain: Record<string, Record<string, number>>
total_evidence: number
evidence_by_status: Record<string, number>
total_risks: number
risks_by_level: Record<string, number>
}
interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
effective_date: string | null
description: string
requirement_count: number
}
interface MappingsData {
total: number
by_regulation: Record<string, number>
}
interface FindingsData {
major_count: number
minor_count: number
ofi_count: number
total: number
open_majors: number
open_minors: number
}
const DOMAIN_LABELS: Record<string, string> = {
gov: 'Governance',
priv: 'Datenschutz',
iam: 'Identity & Access',
crypto: 'Kryptografie',
sdlc: 'Secure Dev',
ops: 'Operations',
ai: 'KI-spezifisch',
cra: 'Supply Chain',
aud: 'Audit',
}
import { useComplianceHub } from './_hooks/useComplianceHub'
import { QuickActions } from './_components/QuickActions'
import { StatsRow, DomainChart } from './_components/StatsRow'
import { MappingsAndFindings } from './_components/MappingsAndFindings'
import { RegulationsTable } from './_components/RegulationsTable'
export default function ComplianceHubPage() {
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
const [regulations, setRegulations] = useState<Regulation[]>([])
const [mappings, setMappings] = useState<MappingsData | null>(null)
const [findings, setFindings] = useState<FindingsData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [seeding, setSeeding] = useState(false)
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
setLoading(true)
setError(null)
try {
const [dashboardRes, regulationsRes, mappingsRes, findingsRes] = await Promise.all([
fetch('/api/sdk/v1/compliance/dashboard'),
fetch('/api/sdk/v1/compliance/regulations'),
fetch('/api/sdk/v1/compliance/mappings'),
fetch('/api/sdk/v1/isms/findings?status=open'),
])
if (dashboardRes.ok) {
setDashboard(await dashboardRes.json())
}
if (regulationsRes.ok) {
const data = await regulationsRes.json()
setRegulations(data.regulations || [])
}
if (mappingsRes.ok) {
const data = await mappingsRes.json()
setMappings(data)
}
if (findingsRes.ok) {
const data = await findingsRes.json()
setFindings(data)
}
} catch (err) {
console.error('Failed to load compliance data:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}
const seedDatabase = async () => {
setSeeding(true)
try {
const res = await fetch('/api/sdk/v1/compliance/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: false }),
})
if (res.ok) {
const result = await res.json()
alert(`Datenbank erfolgreich initialisiert!\n\nRegulations: ${result.counts?.regulations || 0}\nControls: ${result.counts?.controls || 0}\nRequirements: ${result.counts?.requirements || 0}`)
loadData()
} else {
const error = await res.text()
alert(`Fehler beim Seeding: ${error}`)
}
} catch (err) {
console.error('Seeding failed:', err)
alert('Fehler beim Initialisieren der Datenbank')
} finally {
setSeeding(false)
}
}
const {
dashboard, regulations, mappings, findings,
loading, error, seeding,
loadData, seedDatabase,
} = useComplianceHub()
const score = dashboard?.compliance_score || 0
const scoreColor = score >= 80 ? 'text-green-600' : score >= 60 ? 'text-yellow-600' : 'text-red-600'
@@ -184,88 +71,7 @@ export default function ComplianceHubPage() {
)}
{/* Quick Actions */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Schnellzugriff</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
<Link
href="/sdk/audit-checklist"
className="p-4 rounded-lg border border-slate-200 hover:border-purple-500 hover:bg-purple-50 transition-colors text-center"
>
<div className="text-purple-600 mb-2 flex justify-center">
<svg className="w-8 h-8" 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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Audit Checkliste</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_requirements || '...'} Anforderungen</p>
</Link>
<Link
href="/sdk/controls"
className="p-4 rounded-lg border border-slate-200 hover:border-green-500 hover:bg-green-50 transition-colors text-center"
>
<div className="text-green-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Controls</p>
<p className="text-xs text-slate-500 mt-1">{dashboard?.total_controls || '...'} Massnahmen</p>
</Link>
<Link
href="/sdk/evidence"
className="p-4 rounded-lg border border-slate-200 hover:border-blue-500 hover:bg-blue-50 transition-colors text-center"
>
<div className="text-blue-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Evidence</p>
<p className="text-xs text-slate-500 mt-1">Nachweise</p>
</Link>
<Link
href="/sdk/risks"
className="p-4 rounded-lg border border-slate-200 hover:border-red-500 hover:bg-red-50 transition-colors text-center"
>
<div className="text-red-600 mb-2 flex justify-center">
<svg className="w-8 h-8" 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>
<p className="font-medium text-slate-900 text-sm">Risk Matrix</p>
<p className="text-xs text-slate-500 mt-1">5x5 Risiken</p>
</Link>
<Link
href="/sdk/modules"
className="p-4 rounded-lg border border-slate-200 hover:border-pink-500 hover:bg-pink-50 transition-colors text-center"
>
<div className="text-pink-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<p className="font-medium text-slate-900 text-sm">Service Registry</p>
<p className="text-xs text-slate-500 mt-1">Module</p>
</Link>
<Link
href="/sdk/audit-report"
className="p-4 rounded-lg border border-slate-200 hover:border-orange-500 hover:bg-orange-50 transition-colors text-center"
>
<div className="text-orange-600 mb-2 flex justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 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>
</div>
<p className="font-medium text-slate-900 text-sm">Audit Report</p>
<p className="text-xs text-slate-500 mt-1">PDF Export</p>
</Link>
</div>
</div>
<QuickActions dashboard={dashboard} />
{loading ? (
<div className="flex items-center justify-center h-64">
@@ -273,242 +79,15 @@ export default function ComplianceHubPage() {
</div>
) : (
<>
{/* Score and Stats Row */}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
<div className={`text-5xl font-bold ${scoreColor}`}>
{score.toFixed(0)}%
</div>
<div className="mt-4 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${scoreBgColor}`}
style={{ width: `${score}%` }}
/>
</div>
<p className="mt-2 text-sm text-slate-500">
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Verordnungen</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_regulations || 0}</p>
</div>
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 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>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.total_requirements || 0} Anforderungen</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Controls</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_controls || 0}</p>
</div>
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.controls_by_status?.pass || 0} bestanden</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Nachweise</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_evidence || 0}</p>
</div>
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
</div>
<p className="mt-2 text-sm text-slate-500">{dashboard?.evidence_by_status?.valid || 0} aktiv</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Risiken</p>
<p className="text-2xl font-bold text-slate-900">{dashboard?.total_risks || 0}</p>
</div>
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<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>
<p className="mt-2 text-sm text-slate-500">
{(dashboard?.risks_by_level?.high || 0) + (dashboard?.risks_by_level?.critical || 0)} kritisch
</p>
</div>
</div>
{/* Control-Mappings & Findings Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Control-Mappings</h3>
<Link href="/sdk/controls" className="text-sm text-purple-600 hover:text-purple-700">
Alle anzeigen
</Link>
</div>
<div className="flex items-center gap-6 mb-4">
<div>
<p className="text-4xl font-bold text-purple-600">{mappings?.total || 0}</p>
<p className="text-sm text-slate-500">Mappings gesamt</p>
</div>
<div className="flex-1 h-16 bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500 mb-1">Nach Verordnung</p>
<div className="flex gap-1 flex-wrap">
{mappings?.by_regulation && Object.entries(mappings.by_regulation).slice(0, 5).map(([reg, count]) => (
<span key={reg} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{reg}: {count}
</span>
))}
{!mappings?.by_regulation && (
<span className="px-2 py-0.5 bg-slate-100 text-slate-500 rounded text-xs">Keine Mappings vorhanden</span>
)}
</div>
</div>
</div>
<p className="text-sm text-slate-600">
Automatisch generierte Verknuepfungen zwischen {dashboard?.total_controls || 0} Controls
und {dashboard?.total_requirements || 0} Anforderungen aus {dashboard?.total_regulations || 0} Verordnungen.
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Audit Findings</h3>
<Link href="/sdk/audit-checklist" className="text-sm text-purple-600 hover:text-purple-700">
Audit Checkliste
</Link>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-red-500 rounded-full" />
<span className="text-sm font-medium text-red-800">Hauptabweichungen</span>
</div>
<p className="text-3xl font-bold text-red-600">{findings?.open_majors || 0}</p>
<p className="text-xs text-red-600">offen (blockiert Zertifizierung)</p>
</div>
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<div className="w-3 h-3 bg-yellow-500 rounded-full" />
<span className="text-sm font-medium text-yellow-800">Nebenabweichungen</span>
</div>
<p className="text-3xl font-bold text-yellow-600">{findings?.open_minors || 0}</p>
<p className="text-xs text-yellow-600">offen (erfordert CAPA)</p>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">
Gesamt: {findings?.total || 0} Findings ({findings?.major_count || 0} Major, {findings?.minor_count || 0} Minor, {findings?.ofi_count || 0} OFI)
</span>
{(findings?.open_majors || 0) === 0 ? (
<span className="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-medium">
Zertifizierung moeglich
</span>
) : (
<span className="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-medium">
Zertifizierung blockiert
</span>
)}
</div>
</div>
</div>
{/* Domain Chart */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Controls nach Domain</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(dashboard?.controls_by_domain || {}).map(([domain, stats]) => {
const total = stats.total || 0
const pass = stats.pass || 0
const partial = stats.partial || 0
const passPercent = total > 0 ? ((pass + partial * 0.5) / total) * 100 : 0
return (
<div key={domain} className="p-3 rounded-lg bg-slate-50">
<div className="flex justify-between text-sm mb-1">
<span className="font-medium text-slate-700">
{DOMAIN_LABELS[domain] || domain.toUpperCase()}
</span>
<span className="text-slate-500">
{pass}/{total} ({passPercent.toFixed(0)}%)
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden flex">
<div className="bg-green-500 h-full" style={{ width: `${(pass / total) * 100}%` }} />
<div className="bg-yellow-500 h-full" style={{ width: `${(partial / total) * 100}%` }} />
</div>
</div>
)
})}
</div>
</div>
{/* Regulations Table */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="p-4 border-b flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Verordnungen & Standards ({regulations.length})</h3>
<button onClick={loadData} className="text-sm text-purple-600 hover:text-purple-700">
Aktualisieren
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Code</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">Typ</th>
<th className="px-4 py-3 text-center text-xs font-medium text-slate-500 uppercase">Anforderungen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{regulations.slice(0, 15).map((reg) => (
<tr key={reg.id} className="hover:bg-slate-50">
<td className="px-4 py-3">
<span className="font-mono font-medium text-purple-600">{reg.code}</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-slate-900">{reg.name}</p>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
reg.regulation_type === 'eu_regulation' ? 'bg-blue-100 text-blue-700' :
reg.regulation_type === 'eu_directive' ? 'bg-purple-100 text-purple-700' :
reg.regulation_type === 'bsi_standard' ? 'bg-green-100 text-green-700' :
'bg-slate-100 text-slate-700'
}`}>
{reg.regulation_type === 'eu_regulation' ? 'EU-VO' :
reg.regulation_type === 'eu_directive' ? 'EU-RL' :
reg.regulation_type === 'bsi_standard' ? 'BSI' :
reg.regulation_type === 'de_law' ? 'DE' : reg.regulation_type}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="font-medium">{reg.requirement_count}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<StatsRow
dashboard={dashboard}
scoreColor={scoreColor}
scoreBgColor={scoreBgColor}
score={score}
/>
<MappingsAndFindings dashboard={dashboard} mappings={mappings} findings={findings} />
<DomainChart dashboard={dashboard} />
<RegulationsTable regulations={regulations} onRefresh={loadData} />
</>
)}
</div>

View File

@@ -0,0 +1,50 @@
'use client'
import { BannerConfig, BannerTexts, CookieCategory } from '../_hooks/useCookieBanner'
export function BannerPreview({
config,
categories,
bannerTexts,
}: {
config: BannerConfig
categories: CookieCategory[]
bannerTexts: BannerTexts
}) {
return (
<div className="relative bg-gray-100 rounded-xl p-8 min-h-64 flex items-end justify-center">
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-sm">
Website-Vorschau
</div>
<div
className={`w-full max-w-2xl bg-white rounded-xl shadow-xl p-6 border-2 ${
config.position === 'center' ? 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2' : ''
}`}
style={{ borderColor: config.primaryColor }}
>
<h4 className="font-semibold text-gray-900">{bannerTexts.title}</h4>
<p className="text-sm text-gray-600 mt-2">
{bannerTexts.description}
</p>
<div className="mt-4 flex items-center gap-3">
<button
className="px-4 py-2 rounded-lg text-white text-sm font-medium"
style={{ backgroundColor: config.primaryColor }}
>
Alle akzeptieren
</button>
{config.showDeclineAll && (
<button className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium">
Alle ablehnen
</button>
)}
{config.showSettings && (
<button className="px-4 py-2 text-sm text-gray-600 hover:underline">
Einstellungen
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import { useState } from 'react'
import { CookieCategory } from '../_hooks/useCookieBanner'
export function CategoryCard({
category,
onToggle,
}: {
category: CookieCategory
onToggle: (enabled: boolean) => void
}) {
const [expanded, setExpanded] = useState(false)
return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="p-4 flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-gray-900">{category.name}</h4>
{category.required && (
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">Erforderlich</span>
)}
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{category.cookies.length} Cookies
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{category.description}</p>
</div>
<div className="flex items-center gap-4">
<button
onClick={() => setExpanded(!expanded)}
className="text-sm text-purple-600 hover:underline"
>
{expanded ? 'Ausblenden' : 'Details'}
</button>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={category.enabled}
onChange={(e) => onToggle(e.target.checked)}
disabled={category.required}
className="sr-only peer"
/>
<div className={`w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-100 rounded-full peer ${
category.enabled ? 'peer-checked:bg-purple-600' : ''
} peer-disabled:opacity-50 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all ${
category.enabled ? 'after:translate-x-full' : ''
}`} />
</label>
</div>
</div>
{expanded && (
<div className="border-t border-gray-100 p-4 bg-gray-50">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500">
<th className="pb-2">Cookie</th>
<th className="pb-2">Anbieter</th>
<th className="pb-2">Zweck</th>
<th className="pb-2">Ablauf</th>
</tr>
</thead>
<tbody className="text-gray-700">
{category.cookies.map(cookie => (
<tr key={cookie.name}>
<td className="py-1 font-mono text-xs">{cookie.name}</td>
<td className="py-1">{cookie.provider}</td>
<td className="py-1">{cookie.purpose}</td>
<td className="py-1">{cookie.expiry}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import React, { useState } from 'react'
export interface Cookie {
name: string
provider: string
purpose: string
expiry: string
type: 'first-party' | 'third-party'
}
export interface CookieCategory {
id: string
name: string
description: string
required: boolean
enabled: boolean
cookies: Cookie[]
}
export interface BannerConfig {
position: 'bottom' | 'top' | 'center'
style: 'bar' | 'popup' | 'modal'
primaryColor: string
showDeclineAll: boolean
showSettings: boolean
blockScripts: boolean
}
export interface BannerTexts {
title: string
description: string
privacyLink: string
}
const DEFAULT_COOKIE_CATEGORIES: CookieCategory[] = [
{
id: 'necessary',
name: 'Notwendig',
description: 'Diese Cookies sind fuer die Grundfunktionen der Website erforderlich.',
required: true,
enabled: true,
cookies: [
{ name: 'session_id', provider: 'Eigene', purpose: 'Session-Verwaltung', expiry: 'Session', type: 'first-party' },
{ name: 'csrf_token', provider: 'Eigene', purpose: 'Sicherheit', expiry: 'Session', type: 'first-party' },
],
},
{
id: 'analytics',
name: 'Analyse',
description: 'Diese Cookies helfen uns, die Nutzung der Website zu verstehen.',
required: false,
enabled: true,
cookies: [
{ name: '_ga', provider: 'Google Analytics', purpose: 'Nutzeranalyse', expiry: '2 Jahre', type: 'third-party' },
{ name: '_gid', provider: 'Google Analytics', purpose: 'Nutzeranalyse', expiry: '24 Stunden', type: 'third-party' },
],
},
{
id: 'marketing',
name: 'Marketing',
description: 'Diese Cookies werden fuer personalisierte Werbung verwendet.',
required: false,
enabled: false,
cookies: [
{ name: '_fbp', provider: 'Meta (Facebook)', purpose: 'Werbung', expiry: '3 Monate', type: 'third-party' },
{ name: 'IDE', provider: 'Google Ads', purpose: 'Werbung', expiry: '1 Jahr', type: 'third-party' },
],
},
{
id: 'preferences',
name: 'Praeferenzen',
description: 'Diese Cookies speichern Ihre Einstellungen und Praeferenzen.',
required: false,
enabled: true,
cookies: [
{ name: 'language', provider: 'Eigene', purpose: 'Spracheinstellung', expiry: '1 Jahr', type: 'first-party' },
{ name: 'theme', provider: 'Eigene', purpose: 'Design-Einstellung', expiry: '1 Jahr', type: 'first-party' },
],
},
]
const defaultConfig: BannerConfig = {
position: 'bottom',
style: 'bar',
primaryColor: '#6366f1',
showDeclineAll: true,
showSettings: true,
blockScripts: true,
}
const defaultBannerTexts: BannerTexts = {
title: 'Wir verwenden Cookies',
description: 'Wir nutzen Cookies, um Ihnen die bestmoegliche Nutzung unserer Website zu ermoeglichen.',
privacyLink: '/datenschutz',
}
export function useCookieBanner() {
const [categories, setCategories] = useState<CookieCategory[]>([])
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
const [isSaving, setIsSaving] = useState(false)
const [exportToast, setExportToast] = useState<string | null>(null)
React.useEffect(() => {
const loadConfig = async () => {
try {
const response = await fetch('/api/sdk/v1/einwilligungen/cookie-banner/config')
if (response.ok) {
const data = await response.json()
setCategories(data.categories?.length > 0 ? data.categories : DEFAULT_COOKIE_CATEGORIES)
if (data.config && Object.keys(data.config).length > 0) {
setConfig(prev => ({ ...prev, ...data.config }))
const savedTexts = data.config.banner_texts || data.config.texts
if (savedTexts) {
setBannerTexts(prev => ({ ...prev, ...savedTexts }))
}
}
} else {
setCategories(DEFAULT_COOKIE_CATEGORIES)
}
} catch {
setCategories(DEFAULT_COOKIE_CATEGORIES)
}
}
loadConfig()
}, [])
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
setCategories(prev =>
prev.map(cat => cat.id === categoryId ? { ...cat, enabled } : cat)
)
try {
await fetch('/api/sdk/v1/einwilligungen/cookie-banner/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categoryId, enabled }),
})
} catch {
// Silently ignore — local state already updated
}
}
const handleExportCode = async () => {
try {
const res = await fetch('/api/sdk/v1/einwilligungen/cookie-banner/embed-code')
if (res.ok) {
const data = await res.json()
const code = data.embed_code || data.script || ''
await navigator.clipboard.writeText(code)
setExportToast('Embed-Code in Zwischenablage kopiert!')
setTimeout(() => setExportToast(null), 3000)
} else {
setExportToast('Fehler beim Laden des Embed-Codes')
setTimeout(() => setExportToast(null), 3000)
}
} catch {
setExportToast('Fehler beim Kopieren in die Zwischenablage')
setTimeout(() => setExportToast(null), 3000)
}
}
const handleSaveConfig = async () => {
setIsSaving(true)
try {
await fetch('/api/sdk/v1/einwilligungen/cookie-banner/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categories, config: { ...config, banner_texts: bannerTexts } }),
})
} catch {
// Silently ignore
} finally {
setIsSaving(false)
}
}
return {
categories, config, bannerTexts, isSaving, exportToast,
setConfig, setBannerTexts,
handleCategoryToggle, handleExportCode, handleSaveConfig,
}
}

View File

@@ -1,315 +1,19 @@
'use client'
import React, { useState } from 'react'
import React from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// TYPES
// =============================================================================
interface CookieCategory {
id: string
name: string
description: string
required: boolean
enabled: boolean
cookies: Cookie[]
}
interface Cookie {
name: string
provider: string
purpose: string
expiry: string
type: 'first-party' | 'third-party'
}
interface BannerConfig {
position: 'bottom' | 'top' | 'center'
style: 'bar' | 'popup' | 'modal'
primaryColor: string
showDeclineAll: boolean
showSettings: boolean
blockScripts: boolean
}
// =============================================================================
// DEFAULT DATA (Fallback wenn DB leer oder nicht erreichbar)
// =============================================================================
const DEFAULT_COOKIE_CATEGORIES: CookieCategory[] = [
{
id: 'necessary',
name: 'Notwendig',
description: 'Diese Cookies sind fuer die Grundfunktionen der Website erforderlich.',
required: true,
enabled: true,
cookies: [
{ name: 'session_id', provider: 'Eigene', purpose: 'Session-Verwaltung', expiry: 'Session', type: 'first-party' },
{ name: 'csrf_token', provider: 'Eigene', purpose: 'Sicherheit', expiry: 'Session', type: 'first-party' },
],
},
{
id: 'analytics',
name: 'Analyse',
description: 'Diese Cookies helfen uns, die Nutzung der Website zu verstehen.',
required: false,
enabled: true,
cookies: [
{ name: '_ga', provider: 'Google Analytics', purpose: 'Nutzeranalyse', expiry: '2 Jahre', type: 'third-party' },
{ name: '_gid', provider: 'Google Analytics', purpose: 'Nutzeranalyse', expiry: '24 Stunden', type: 'third-party' },
],
},
{
id: 'marketing',
name: 'Marketing',
description: 'Diese Cookies werden fuer personalisierte Werbung verwendet.',
required: false,
enabled: false,
cookies: [
{ name: '_fbp', provider: 'Meta (Facebook)', purpose: 'Werbung', expiry: '3 Monate', type: 'third-party' },
{ name: 'IDE', provider: 'Google Ads', purpose: 'Werbung', expiry: '1 Jahr', type: 'third-party' },
],
},
{
id: 'preferences',
name: 'Praeferenzen',
description: 'Diese Cookies speichern Ihre Einstellungen und Praeferenzen.',
required: false,
enabled: true,
cookies: [
{ name: 'language', provider: 'Eigene', purpose: 'Spracheinstellung', expiry: '1 Jahr', type: 'first-party' },
{ name: 'theme', provider: 'Eigene', purpose: 'Design-Einstellung', expiry: '1 Jahr', type: 'first-party' },
],
},
]
const defaultConfig: BannerConfig = {
position: 'bottom',
style: 'bar',
primaryColor: '#6366f1',
showDeclineAll: true,
showSettings: true,
blockScripts: true,
}
// =============================================================================
// COMPONENTS
// =============================================================================
interface BannerTexts {
title: string
description: string
privacyLink: string
}
function BannerPreview({ config, categories, bannerTexts }: { config: BannerConfig; categories: CookieCategory[]; bannerTexts: BannerTexts }) {
return (
<div className="relative bg-gray-100 rounded-xl p-8 min-h-64 flex items-end justify-center">
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-sm">
Website-Vorschau
</div>
<div
className={`w-full max-w-2xl bg-white rounded-xl shadow-xl p-6 border-2 ${
config.position === 'center' ? 'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2' : ''
}`}
style={{ borderColor: config.primaryColor }}
>
<h4 className="font-semibold text-gray-900">{bannerTexts.title}</h4>
<p className="text-sm text-gray-600 mt-2">
{bannerTexts.description}
</p>
<div className="mt-4 flex items-center gap-3">
<button
className="px-4 py-2 rounded-lg text-white text-sm font-medium"
style={{ backgroundColor: config.primaryColor }}
>
Alle akzeptieren
</button>
{config.showDeclineAll && (
<button className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium">
Alle ablehnen
</button>
)}
{config.showSettings && (
<button className="px-4 py-2 text-sm text-gray-600 hover:underline">
Einstellungen
</button>
)}
</div>
</div>
</div>
)
}
function CategoryCard({
category,
onToggle,
}: {
category: CookieCategory
onToggle: (enabled: boolean) => void
}) {
const [expanded, setExpanded] = useState(false)
return (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="p-4 flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-gray-900">{category.name}</h4>
{category.required && (
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded">Erforderlich</span>
)}
<span className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
{category.cookies.length} Cookies
</span>
</div>
<p className="text-sm text-gray-500 mt-1">{category.description}</p>
</div>
<div className="flex items-center gap-4">
<button
onClick={() => setExpanded(!expanded)}
className="text-sm text-purple-600 hover:underline"
>
{expanded ? 'Ausblenden' : 'Details'}
</button>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={category.enabled}
onChange={(e) => onToggle(e.target.checked)}
disabled={category.required}
className="sr-only peer"
/>
<div className={`w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-100 rounded-full peer ${
category.enabled ? 'peer-checked:bg-purple-600' : ''
} peer-disabled:opacity-50 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all ${
category.enabled ? 'after:translate-x-full' : ''
}`} />
</label>
</div>
</div>
{expanded && (
<div className="border-t border-gray-100 p-4 bg-gray-50">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500">
<th className="pb-2">Cookie</th>
<th className="pb-2">Anbieter</th>
<th className="pb-2">Zweck</th>
<th className="pb-2">Ablauf</th>
</tr>
</thead>
<tbody className="text-gray-700">
{category.cookies.map(cookie => (
<tr key={cookie.name}>
<td className="py-1 font-mono text-xs">{cookie.name}</td>
<td className="py-1">{cookie.provider}</td>
<td className="py-1">{cookie.purpose}</td>
<td className="py-1">{cookie.expiry}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
const defaultBannerTexts: BannerTexts = {
title: 'Wir verwenden Cookies',
description: 'Wir nutzen Cookies, um Ihnen die bestmoegliche Nutzung unserer Website zu ermoeglichen.',
privacyLink: '/datenschutz',
}
import { useCookieBanner } from './_hooks/useCookieBanner'
import { BannerPreview } from './_components/BannerPreview'
import { CategoryCard } from './_components/CategoryCard'
export default function CookieBannerPage() {
const { state } = useSDK()
const [categories, setCategories] = useState<CookieCategory[]>([])
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
const [isSaving, setIsSaving] = useState(false)
const [exportToast, setExportToast] = useState<string | null>(null)
React.useEffect(() => {
const loadConfig = async () => {
try {
const response = await fetch('/api/sdk/v1/einwilligungen/cookie-banner/config')
if (response.ok) {
const data = await response.json()
// DB-Kategorien haben immer Vorrang — Defaults nur wenn DB wirklich leer
setCategories(data.categories?.length > 0 ? data.categories : DEFAULT_COOKIE_CATEGORIES)
if (data.config && Object.keys(data.config).length > 0) {
setConfig(prev => ({ ...prev, ...data.config }))
const savedTexts = data.config.banner_texts || data.config.texts
if (savedTexts) {
setBannerTexts(prev => ({ ...prev, ...savedTexts }))
}
}
} else {
setCategories(DEFAULT_COOKIE_CATEGORIES)
}
} catch {
setCategories(DEFAULT_COOKIE_CATEGORIES)
}
}
loadConfig()
}, [])
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
setCategories(prev =>
prev.map(cat => cat.id === categoryId ? { ...cat, enabled } : cat)
)
try {
await fetch('/api/sdk/v1/einwilligungen/cookie-banner/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categoryId, enabled }),
})
} catch {
// Silently ignore — local state already updated
}
}
const handleExportCode = async () => {
try {
const res = await fetch('/api/sdk/v1/einwilligungen/cookie-banner/embed-code')
if (res.ok) {
const data = await res.json()
const code = data.embed_code || data.script || ''
await navigator.clipboard.writeText(code)
setExportToast('Embed-Code in Zwischenablage kopiert!')
setTimeout(() => setExportToast(null), 3000)
} else {
setExportToast('Fehler beim Laden des Embed-Codes')
setTimeout(() => setExportToast(null), 3000)
}
} catch {
setExportToast('Fehler beim Kopieren in die Zwischenablage')
setTimeout(() => setExportToast(null), 3000)
}
}
const handleSaveConfig = async () => {
setIsSaving(true)
try {
await fetch('/api/sdk/v1/einwilligungen/cookie-banner/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categories, config: { ...config, banner_texts: bannerTexts } }),
})
} catch {
// Silently ignore
} finally {
setIsSaving(false)
}
}
const {
categories, config, bannerTexts, isSaving, exportToast,
setConfig, setBannerTexts,
handleCategoryToggle, handleExportCode, handleSaveConfig,
} = useCookieBanner()
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
const thirdPartyCookies = categories.reduce(

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

View File

@@ -0,0 +1,71 @@
'use client'
const EVENT_TYPE_CONFIG: Record<string, { label: string; color: string; bgColor: string; icon: string }> = {
incident: {
label: 'Vorfall',
color: 'text-red-700',
bgColor: 'bg-red-100',
icon: '🚨',
},
update: {
label: 'Update',
color: 'text-blue-700',
bgColor: 'bg-blue-100',
icon: '🔄',
},
drift_alert: {
label: 'Drift-Warnung',
color: 'text-orange-700',
bgColor: 'bg-orange-100',
icon: '📉',
},
regulation_change: {
label: 'Regulierungsaenderung',
color: 'text-purple-700',
bgColor: 'bg-purple-100',
icon: '📜',
},
}
const SEVERITY_CONFIG: Record<string, { label: string; color: string }> = {
low: { label: 'Niedrig', color: 'bg-green-100 text-green-700' },
medium: { label: 'Mittel', color: 'bg-yellow-100 text-yellow-700' },
high: { label: 'Hoch', color: 'bg-orange-100 text-orange-700' },
critical: { label: 'Kritisch', color: 'bg-red-100 text-red-700' },
}
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
open: { label: 'Offen', color: 'bg-red-100 text-red-700' },
investigating: { label: 'In Untersuchung', color: 'bg-yellow-100 text-yellow-700' },
resolved: { label: 'Geloest', color: 'bg-green-100 text-green-700' },
closed: { label: 'Geschlossen', color: 'bg-gray-100 text-gray-700' },
}
export { EVENT_TYPE_CONFIG }
export function EventTypeBadge({ type }: { type: string }) {
const config = EVENT_TYPE_CONFIG[type] || EVENT_TYPE_CONFIG.incident
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
{config.icon} {config.label}
</span>
)
}
export function SeverityBadge({ severity }: { severity: string }) {
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.low
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
export function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.open
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import { useState } from 'react'
import { EventFormData } from '../_hooks/useMonitoring'
export function EventForm({
onSubmit,
onCancel,
}: {
onSubmit: (data: EventFormData) => void
onCancel: () => void
}) {
const [formData, setFormData] = useState<EventFormData>({
event_type: 'incident',
title: '',
description: '',
severity: 'medium',
})
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Monitoring-Ereignis</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. KI-Modell Drift erkannt"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
<select
value={formData.event_type}
onChange={(e) => setFormData({ ...formData, event_type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="incident">Vorfall</option>
<option value="update">Update</option>
<option value="drift_alert">Drift-Warnung</option>
<option value="regulation_change">Regulierungsaenderung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schwere</label>
<select
value={formData.severity}
onChange={(e) => setFormData({ ...formData, severity: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="critical">Kritisch</option>
</select>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
placeholder="Beschreiben Sie das Ereignis..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Ereignis erfassen
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useState } from 'react'
import { MonitoringEvent } from '../_hooks/useMonitoring'
export function ResolveModal({
event,
onSubmit,
onClose,
}: {
event: MonitoringEvent
onSubmit: (id: string, notes: string) => void
onClose: () => void
}) {
const [notes, setNotes] = useState('')
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Ereignis loesen: {event.title}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Loesung / Massnahmen
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
placeholder="Beschreiben Sie die durchgefuehrten Massnahmen..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
onClick={() => onSubmit(event.id, notes)}
disabled={!notes}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
notes
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Als geloest markieren
</button>
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { MonitoringEvent } from '../_hooks/useMonitoring'
import { EventTypeBadge, SeverityBadge, StatusBadge, EVENT_TYPE_CONFIG } from './Badges'
export function TimelineEvent({
event,
onResolve,
}: {
event: MonitoringEvent
onResolve: (event: MonitoringEvent) => void
}) {
const typeConfig = EVENT_TYPE_CONFIG[event.event_type] || EVENT_TYPE_CONFIG.incident
const lineColor = event.status === 'resolved' || event.status === 'closed' ? 'bg-green-300' : 'bg-gray-300'
return (
<div className="relative flex gap-4 pb-8 last:pb-0">
{/* Timeline line */}
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg ${typeConfig.bgColor} flex-shrink-0`}>
{typeConfig.icon}
</div>
<div className={`w-0.5 flex-1 ${lineColor} mt-2`} />
</div>
{/* Content */}
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 -mt-1">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">{event.title}</h4>
<div className="flex items-center gap-2 mt-1">
<EventTypeBadge type={event.event_type} />
<SeverityBadge severity={event.severity} />
<StatusBadge status={event.status} />
</div>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
{new Date(event.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
{event.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">{event.description}</p>
)}
{event.resolution_notes && (
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">Loesung:</div>
<p className="text-sm text-green-800 dark:text-green-300">{event.resolution_notes}</p>
{event.resolved_at && (
<div className="text-xs text-green-600 mt-1">
Geloest am {new Date(event.resolved_at).toLocaleDateString('de-DE')}
</div>
)}
</div>
)}
{(event.status === 'open' || event.status === 'investigating') && (
<div className="mt-3">
<button
onClick={() => onResolve(event)}
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Loesen
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
'use client'
import { useState, useEffect } from 'react'
export interface MonitoringEvent {
id: string
event_type: 'incident' | 'update' | 'drift_alert' | 'regulation_change'
title: string
description: string
severity: 'low' | 'medium' | 'high' | 'critical'
status: 'open' | 'investigating' | 'resolved' | 'closed'
created_at: string
resolved_at: string | null
resolved_by: string | null
resolution_notes: string | null
}
export interface EventFormData {
event_type: string
title: string
description: string
severity: string
}
export function useMonitoring(projectId: string) {
const [events, setEvents] = useState<MonitoringEvent[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [resolvingEvent, setResolvingEvent] = useState<MonitoringEvent | null>(null)
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
useEffect(() => {
fetchEvents()
}, [projectId])
async function fetchEvents() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`)
if (res.ok) {
const json = await res.json()
setEvents(json.events || json || [])
}
} catch (err) {
console.error('Failed to fetch monitoring events:', err)
} finally {
setLoading(false)
}
}
async function handleSubmit(data: EventFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
setShowForm(false)
await fetchEvents()
}
} catch (err) {
console.error('Failed to add event:', err)
}
}
async function handleResolve(id: string, notes: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolution_notes: notes }),
})
if (res.ok) {
setResolvingEvent(null)
await fetchEvents()
}
} catch (err) {
console.error('Failed to resolve event:', err)
}
}
const filteredEvents = events.filter((e) => {
const matchType = !filterType || e.event_type === filterType
const matchStatus = !filterStatus || e.status === filterStatus
return matchType && matchStatus
})
const openCount = events.filter((e) => e.status === 'open' || e.status === 'investigating').length
const resolvedCount = events.filter((e) => e.status === 'resolved' || e.status === 'closed').length
return {
events, loading, showForm, resolvingEvent,
filterType, filterStatus, filteredEvents,
openCount, resolvedCount,
setShowForm, setResolvingEvent, setFilterType, setFilterStatus,
handleSubmit, handleResolve,
}
}

View File

@@ -1,378 +1,23 @@
'use client'
import React, { useState, useEffect } from 'react'
import React from 'react'
import { useParams } from 'next/navigation'
interface MonitoringEvent {
id: string
event_type: 'incident' | 'update' | 'drift_alert' | 'regulation_change'
title: string
description: string
severity: 'low' | 'medium' | 'high' | 'critical'
status: 'open' | 'investigating' | 'resolved' | 'closed'
created_at: string
resolved_at: string | null
resolved_by: string | null
resolution_notes: string | null
}
const EVENT_TYPE_CONFIG: Record<string, { label: string; color: string; bgColor: string; icon: string }> = {
incident: {
label: 'Vorfall',
color: 'text-red-700',
bgColor: 'bg-red-100',
icon: '🚨',
},
update: {
label: 'Update',
color: 'text-blue-700',
bgColor: 'bg-blue-100',
icon: '🔄',
},
drift_alert: {
label: 'Drift-Warnung',
color: 'text-orange-700',
bgColor: 'bg-orange-100',
icon: '📉',
},
regulation_change: {
label: 'Regulierungsaenderung',
color: 'text-purple-700',
bgColor: 'bg-purple-100',
icon: '📜',
},
}
const SEVERITY_CONFIG: Record<string, { label: string; color: string }> = {
low: { label: 'Niedrig', color: 'bg-green-100 text-green-700' },
medium: { label: 'Mittel', color: 'bg-yellow-100 text-yellow-700' },
high: { label: 'Hoch', color: 'bg-orange-100 text-orange-700' },
critical: { label: 'Kritisch', color: 'bg-red-100 text-red-700' },
}
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
open: { label: 'Offen', color: 'bg-red-100 text-red-700' },
investigating: { label: 'In Untersuchung', color: 'bg-yellow-100 text-yellow-700' },
resolved: { label: 'Geloest', color: 'bg-green-100 text-green-700' },
closed: { label: 'Geschlossen', color: 'bg-gray-100 text-gray-700' },
}
function EventTypeBadge({ type }: { type: string }) {
const config = EVENT_TYPE_CONFIG[type] || EVENT_TYPE_CONFIG.incident
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.bgColor} ${config.color}`}>
{config.icon} {config.label}
</span>
)
}
function SeverityBadge({ severity }: { severity: string }) {
const config = SEVERITY_CONFIG[severity] || SEVERITY_CONFIG.low
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
function StatusBadge({ status }: { status: string }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.open
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
interface EventFormData {
event_type: string
title: string
description: string
severity: string
}
function EventForm({
onSubmit,
onCancel,
}: {
onSubmit: (data: EventFormData) => void
onCancel: () => void
}) {
const [formData, setFormData] = useState<EventFormData>({
event_type: 'incident',
title: '',
description: '',
severity: 'medium',
})
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Monitoring-Ereignis</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="z.B. KI-Modell Drift erkannt"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Typ</label>
<select
value={formData.event_type}
onChange={(e) => setFormData({ ...formData, event_type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="incident">Vorfall</option>
<option value="update">Update</option>
<option value="drift_alert">Drift-Warnung</option>
<option value="regulation_change">Regulierungsaenderung</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Schwere</label>
<select
value={formData.severity}
onChange={(e) => setFormData({ ...formData, severity: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
<option value="critical">Kritisch</option>
</select>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
placeholder="Beschreiben Sie das Ereignis..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<div className="mt-4 flex items-center gap-3">
<button
onClick={() => onSubmit(formData)}
disabled={!formData.title}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
formData.title
? 'bg-purple-600 text-white hover:bg-purple-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Ereignis erfassen
</button>
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
)
}
function ResolveModal({
event,
onSubmit,
onClose,
}: {
event: MonitoringEvent
onSubmit: (id: string, notes: string) => void
onClose: () => void
}) {
const [notes, setNotes] = useState('')
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Ereignis loesen: {event.title}
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Loesung / Massnahmen
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
placeholder="Beschreiben Sie die durchgefuehrten Massnahmen..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button
onClick={() => onSubmit(event.id, notes)}
disabled={!notes}
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
notes
? 'bg-green-600 text-white hover:bg-green-700'
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
}`}
>
Als geloest markieren
</button>
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
</div>
</div>
</div>
)
}
function TimelineEvent({
event,
onResolve,
}: {
event: MonitoringEvent
onResolve: (event: MonitoringEvent) => void
}) {
const typeConfig = EVENT_TYPE_CONFIG[event.event_type] || EVENT_TYPE_CONFIG.incident
const lineColor = event.status === 'resolved' || event.status === 'closed' ? 'bg-green-300' : 'bg-gray-300'
return (
<div className="relative flex gap-4 pb-8 last:pb-0">
{/* Timeline line */}
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-lg ${typeConfig.bgColor} flex-shrink-0`}>
{typeConfig.icon}
</div>
<div className={`w-0.5 flex-1 ${lineColor} mt-2`} />
</div>
{/* Content */}
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 -mt-1">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">{event.title}</h4>
<div className="flex items-center gap-2 mt-1">
<EventTypeBadge type={event.event_type} />
<SeverityBadge severity={event.severity} />
<StatusBadge status={event.status} />
</div>
</div>
<span className="text-xs text-gray-400 flex-shrink-0">
{new Date(event.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
{event.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-2">{event.description}</p>
)}
{event.resolution_notes && (
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<div className="text-xs font-medium text-green-700 dark:text-green-400 mb-1">Loesung:</div>
<p className="text-sm text-green-800 dark:text-green-300">{event.resolution_notes}</p>
{event.resolved_at && (
<div className="text-xs text-green-600 mt-1">
Geloest am {new Date(event.resolved_at).toLocaleDateString('de-DE')}
</div>
)}
</div>
)}
{(event.status === 'open' || event.status === 'investigating') && (
<div className="mt-3">
<button
onClick={() => onResolve(event)}
className="text-sm px-3 py-1.5 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Loesen
</button>
</div>
)}
</div>
</div>
)
}
import { useMonitoring } from './_hooks/useMonitoring'
import { EventForm } from './_components/EventForm'
import { ResolveModal } from './_components/ResolveModal'
import { TimelineEvent } from './_components/TimelineEvent'
export default function MonitoringPage() {
const params = useParams()
const projectId = params.projectId as string
const [events, setEvents] = useState<MonitoringEvent[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [resolvingEvent, setResolvingEvent] = useState<MonitoringEvent | null>(null)
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
useEffect(() => {
fetchEvents()
}, [projectId])
async function fetchEvents() {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`)
if (res.ok) {
const json = await res.json()
setEvents(json.events || json || [])
}
} catch (err) {
console.error('Failed to fetch monitoring events:', err)
} finally {
setLoading(false)
}
}
async function handleSubmit(data: EventFormData) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
setShowForm(false)
await fetchEvents()
}
} catch (err) {
console.error('Failed to add event:', err)
}
}
async function handleResolve(id: string, notes: string) {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolution_notes: notes }),
})
if (res.ok) {
setResolvingEvent(null)
await fetchEvents()
}
} catch (err) {
console.error('Failed to resolve event:', err)
}
}
const filteredEvents = events.filter((e) => {
const matchType = !filterType || e.event_type === filterType
const matchStatus = !filterStatus || e.status === filterStatus
return matchType && matchStatus
})
const openCount = events.filter((e) => e.status === 'open' || e.status === 'investigating').length
const resolvedCount = events.filter((e) => e.status === 'resolved' || e.status === 'closed').length
const {
events, loading, showForm, resolvingEvent,
filterType, filterStatus, filteredEvents,
openCount, resolvedCount,
setShowForm, setResolvingEvent, setFilterType, setFilterStatus,
handleSubmit, handleResolve,
} = useMonitoring(projectId)
if (loading) {
return (