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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
142
admin-compliance/app/sdk/compliance-hub/_components/StatsRow.tsx
Normal file
142
admin-compliance/app/sdk/compliance-hub/_components/StatsRow.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
184
admin-compliance/app/sdk/cookie-banner/_hooks/useCookieBanner.ts
Normal file
184
admin-compliance/app/sdk/cookie-banner/_hooks/useCookieBanner.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
111
admin-compliance/app/sdk/dsr/new/_hooks/useNewDSRForm.ts
Normal file
111
admin-compliance/app/sdk/dsr/new/_hooks/useNewDSRForm.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user