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
|
* - Regulations overview
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useComplianceHub } from './_hooks/useComplianceHub'
|
||||||
import Link from 'next/link'
|
import { QuickActions } from './_components/QuickActions'
|
||||||
|
import { StatsRow, DomainChart } from './_components/StatsRow'
|
||||||
// Types
|
import { MappingsAndFindings } from './_components/MappingsAndFindings'
|
||||||
interface DashboardData {
|
import { RegulationsTable } from './_components/RegulationsTable'
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ComplianceHubPage() {
|
export default function ComplianceHubPage() {
|
||||||
const [dashboard, setDashboard] = useState<DashboardData | null>(null)
|
const {
|
||||||
const [regulations, setRegulations] = useState<Regulation[]>([])
|
dashboard, regulations, mappings, findings,
|
||||||
const [mappings, setMappings] = useState<MappingsData | null>(null)
|
loading, error, seeding,
|
||||||
const [findings, setFindings] = useState<FindingsData | null>(null)
|
loadData, seedDatabase,
|
||||||
const [loading, setLoading] = useState(true)
|
} = useComplianceHub()
|
||||||
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 score = dashboard?.compliance_score || 0
|
const score = dashboard?.compliance_score || 0
|
||||||
const scoreColor = score >= 80 ? 'text-green-600' : score >= 60 ? 'text-yellow-600' : 'text-red-600'
|
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 */}
|
{/* Quick Actions */}
|
||||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
<QuickActions dashboard={dashboard} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@@ -273,242 +79,15 @@ export default function ComplianceHubPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Score and Stats Row */}
|
<StatsRow
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
|
dashboard={dashboard}
|
||||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
scoreColor={scoreColor}
|
||||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Compliance Score</h3>
|
scoreBgColor={scoreBgColor}
|
||||||
<div className={`text-5xl font-bold ${scoreColor}`}>
|
score={score}
|
||||||
{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>
|
<MappingsAndFindings dashboard={dashboard} mappings={mappings} findings={findings} />
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
<DomainChart dashboard={dashboard} />
|
||||||
{dashboard?.controls_by_status?.pass || 0} von {dashboard?.total_controls || 0} Controls bestanden
|
<RegulationsTable regulations={regulations} onRefresh={loadData} />
|
||||||
</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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import { useSDK } from '@/lib/sdk'
|
import { useSDK } from '@/lib/sdk'
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
|
import { useCookieBanner } from './_hooks/useCookieBanner'
|
||||||
// =============================================================================
|
import { BannerPreview } from './_components/BannerPreview'
|
||||||
// TYPES
|
import { CategoryCard } from './_components/CategoryCard'
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CookieBannerPage() {
|
export default function CookieBannerPage() {
|
||||||
const { state } = useSDK()
|
const { state } = useSDK()
|
||||||
const [categories, setCategories] = useState<CookieCategory[]>([])
|
const {
|
||||||
const [config, setConfig] = useState<BannerConfig>(defaultConfig)
|
categories, config, bannerTexts, isSaving, exportToast,
|
||||||
const [bannerTexts, setBannerTexts] = useState<BannerTexts>(defaultBannerTexts)
|
setConfig, setBannerTexts,
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
handleCategoryToggle, handleExportCode, handleSaveConfig,
|
||||||
const [exportToast, setExportToast] = useState<string | null>(null)
|
} = useCookieBanner()
|
||||||
|
|
||||||
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 totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
|
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
|
||||||
const thirdPartyCookies = categories.reduce(
|
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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { DSRType, DSRPriority } from '@/lib/sdk/dsr/types'
|
||||||
import {
|
import { TypeSelector } from './_components/TypeSelector'
|
||||||
DSRType,
|
import { SourceSelector } from './_components/SourceSelector'
|
||||||
DSRSource,
|
import { useNewDSRForm } from './_hooks/useNewDSRForm'
|
||||||
DSRPriority,
|
|
||||||
DSR_TYPE_INFO,
|
|
||||||
DSRCreateRequest
|
|
||||||
} from '@/lib/sdk/dsr/types'
|
|
||||||
import { createSDKDSR } from '@/lib/sdk/dsr/api'
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// TYPES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface FormData {
|
|
||||||
type: DSRType | ''
|
|
||||||
requesterName: string
|
|
||||||
requesterEmail: string
|
|
||||||
requesterPhone: string
|
|
||||||
requesterAddress: string
|
|
||||||
source: DSRSource | ''
|
|
||||||
sourceDetails: string
|
|
||||||
requestText: string
|
|
||||||
priority: DSRPriority
|
|
||||||
customerId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// COMPONENTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function TypeSelector({
|
|
||||||
selectedType,
|
|
||||||
onSelect
|
|
||||||
}: {
|
|
||||||
selectedType: DSRType | ''
|
|
||||||
onSelect: (type: DSRType) => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
Art der Anfrage <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
|
||||||
{Object.entries(DSR_TYPE_INFO).map(([type, info]) => (
|
|
||||||
<button
|
|
||||||
key={type}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(type as DSRType)}
|
|
||||||
className={`
|
|
||||||
p-4 rounded-xl border-2 text-left transition-all
|
|
||||||
${selectedType === type
|
|
||||||
? 'border-purple-500 bg-purple-50'
|
|
||||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className={`
|
|
||||||
w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0
|
|
||||||
${selectedType === type ? 'bg-purple-100' : info.bgColor}
|
|
||||||
`}>
|
|
||||||
<span className={`text-sm font-bold ${selectedType === type ? 'text-purple-600' : info.color}`}>
|
|
||||||
{info.article.split(' ')[1]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className={`font-medium ${selectedType === type ? 'text-purple-700' : 'text-gray-900'}`}>
|
|
||||||
{info.labelShort}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">
|
|
||||||
{info.article}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{selectedType && (
|
|
||||||
<div className={`p-4 rounded-xl ${DSR_TYPE_INFO[selectedType].bgColor} border border-gray-200`}>
|
|
||||||
<div className={`font-medium ${DSR_TYPE_INFO[selectedType].color}`}>
|
|
||||||
{DSR_TYPE_INFO[selectedType].label}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
{DSR_TYPE_INFO[selectedType].description}
|
|
||||||
</p>
|
|
||||||
<div className="text-xs text-gray-500 mt-2">
|
|
||||||
Standardfrist: {DSR_TYPE_INFO[selectedType].defaultDeadlineDays} Tage
|
|
||||||
{DSR_TYPE_INFO[selectedType].maxExtensionMonths > 0 && (
|
|
||||||
<> | Verlaengerbar um {DSR_TYPE_INFO[selectedType].maxExtensionMonths} Monate</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SourceSelector({
|
|
||||||
selectedSource,
|
|
||||||
sourceDetails,
|
|
||||||
onSourceChange,
|
|
||||||
onDetailsChange
|
|
||||||
}: {
|
|
||||||
selectedSource: DSRSource | ''
|
|
||||||
sourceDetails: string
|
|
||||||
onSourceChange: (source: DSRSource) => void
|
|
||||||
onDetailsChange: (details: string) => void
|
|
||||||
}) {
|
|
||||||
const sources: { value: DSRSource; label: string; icon: string }[] = [
|
|
||||||
{ value: 'web_form', label: 'Webformular', icon: 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9' },
|
|
||||||
{ value: 'email', label: 'E-Mail', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
|
||||||
{ value: 'letter', label: 'Brief', icon: 'M3 19v-8.93a2 2 0 01.89-1.664l7-4.666a2 2 0 012.22 0l7 4.666A2 2 0 0121 10.07V19M3 19a2 2 0 002 2h14a2 2 0 002-2M3 19l6.75-4.5M21 19l-6.75-4.5M3 10l6.75 4.5M21 10l-6.75 4.5' },
|
|
||||||
{ value: 'phone', label: 'Telefon', icon: 'M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z' },
|
|
||||||
{ value: 'in_person', label: 'Persoenlich', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
|
|
||||||
{ value: 'other', label: 'Sonstiges', icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' }
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
Quelle der Anfrage <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<div className="grid grid-cols-3 md:grid-cols-6 gap-2">
|
|
||||||
{sources.map(source => (
|
|
||||||
<button
|
|
||||||
key={source.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSourceChange(source.value)}
|
|
||||||
className={`
|
|
||||||
p-3 rounded-xl border-2 text-center transition-all
|
|
||||||
${selectedSource === source.value
|
|
||||||
? 'border-purple-500 bg-purple-50'
|
|
||||||
: 'border-gray-200 hover:border-gray-300'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={`w-6 h-6 mx-auto ${selectedSource === source.value ? 'text-purple-600' : 'text-gray-400'}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={source.icon} />
|
|
||||||
</svg>
|
|
||||||
<div className={`text-xs mt-1 ${selectedSource === source.value ? 'text-purple-600 font-medium' : 'text-gray-500'}`}>
|
|
||||||
{source.label}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{selectedSource && (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={sourceDetails}
|
|
||||||
onChange={(e) => onDetailsChange(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
selectedSource === 'web_form' ? 'z.B. Kontaktformular auf website.de' :
|
|
||||||
selectedSource === 'email' ? 'z.B. info@firma.de' :
|
|
||||||
selectedSource === 'phone' ? 'z.B. Anruf am 22.01.2025' :
|
|
||||||
'Weitere Details zur Quelle'
|
|
||||||
}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// MAIN PAGE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function NewDSRPage() {
|
export default function NewDSRPage() {
|
||||||
const router = useRouter()
|
const { formData, errors, isSubmitting, updateField, handleSubmit } = useNewDSRForm()
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState<FormData>({
|
|
||||||
type: '',
|
|
||||||
requesterName: '',
|
|
||||||
requesterEmail: '',
|
|
||||||
requesterPhone: '',
|
|
||||||
requesterAddress: '',
|
|
||||||
source: '',
|
|
||||||
sourceDetails: '',
|
|
||||||
requestText: '',
|
|
||||||
priority: 'normal',
|
|
||||||
customerId: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateField = <K extends keyof FormData>(field: K, value: FormData[K]) => {
|
|
||||||
setFormData(prev => ({ ...prev, [field]: value }))
|
|
||||||
// Clear error when field is updated
|
|
||||||
if (errors[field]) {
|
|
||||||
setErrors(prev => {
|
|
||||||
const newErrors = { ...prev }
|
|
||||||
delete newErrors[field]
|
|
||||||
return newErrors
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validate = (): boolean => {
|
|
||||||
const newErrors: Record<string, string> = {}
|
|
||||||
|
|
||||||
if (!formData.type) {
|
|
||||||
newErrors.type = 'Bitte waehlen Sie den Anfragetyp'
|
|
||||||
}
|
|
||||||
if (!formData.requesterName.trim()) {
|
|
||||||
newErrors.requesterName = 'Name ist erforderlich'
|
|
||||||
}
|
|
||||||
if (!formData.requesterEmail.trim()) {
|
|
||||||
newErrors.requesterEmail = 'E-Mail ist erforderlich'
|
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.requesterEmail)) {
|
|
||||||
newErrors.requesterEmail = 'Bitte geben Sie eine gueltige E-Mail-Adresse ein'
|
|
||||||
}
|
|
||||||
if (!formData.source) {
|
|
||||||
newErrors.source = 'Bitte waehlen Sie die Quelle der Anfrage'
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors)
|
|
||||||
return Object.keys(newErrors).length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (!validate()) return
|
|
||||||
|
|
||||||
setIsSubmitting(true)
|
|
||||||
try {
|
|
||||||
// Create DSR request
|
|
||||||
const request: DSRCreateRequest = {
|
|
||||||
type: formData.type as DSRType,
|
|
||||||
requester: {
|
|
||||||
name: formData.requesterName,
|
|
||||||
email: formData.requesterEmail,
|
|
||||||
phone: formData.requesterPhone || undefined,
|
|
||||||
address: formData.requesterAddress || undefined,
|
|
||||||
customerId: formData.customerId || undefined
|
|
||||||
},
|
|
||||||
source: formData.source as DSRSource,
|
|
||||||
sourceDetails: formData.sourceDetails || undefined,
|
|
||||||
requestText: formData.requestText || undefined,
|
|
||||||
priority: formData.priority
|
|
||||||
}
|
|
||||||
|
|
||||||
await createSDKDSR(request)
|
|
||||||
|
|
||||||
// Redirect to DSR list
|
|
||||||
router.push('/sdk/dsr')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create DSR:', error)
|
|
||||||
setErrors({ submit: 'Fehler beim Erstellen der Anfrage. Bitte versuchen Sie es erneut.' })
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -300,7 +48,6 @@ export default function NewDSRPage() {
|
|||||||
<h2 className="text-lg font-semibold text-gray-900">Antragsteller</h2>
|
<h2 className="text-lg font-semibold text-gray-900">Antragsteller</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Name */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Name <span className="text-red-500">*</span>
|
Name <span className="text-red-500">*</span>
|
||||||
@@ -320,7 +67,6 @@ export default function NewDSRPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
E-Mail <span className="text-red-500">*</span>
|
E-Mail <span className="text-red-500">*</span>
|
||||||
@@ -340,7 +86,6 @@ export default function NewDSRPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Telefon (optional)
|
Telefon (optional)
|
||||||
@@ -354,7 +99,6 @@ export default function NewDSRPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Customer ID */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Kunden-ID (optional)
|
Kunden-ID (optional)
|
||||||
@@ -369,7 +113,6 @@ export default function NewDSRPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Adresse (optional)
|
Adresse (optional)
|
||||||
@@ -398,7 +141,6 @@ export default function NewDSRPage() {
|
|||||||
<p className="mt-1 text-sm text-red-600">{errors.source}</p>
|
<p className="mt-1 text-sm text-red-600">{errors.source}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Request Text */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Anfrage-Text (optional)
|
Anfrage-Text (optional)
|
||||||
@@ -415,7 +157,6 @@ export default function NewDSRPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Prioritaet
|
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'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
|
import { useMonitoring } from './_hooks/useMonitoring'
|
||||||
interface MonitoringEvent {
|
import { EventForm } from './_components/EventForm'
|
||||||
id: string
|
import { ResolveModal } from './_components/ResolveModal'
|
||||||
event_type: 'incident' | 'update' | 'drift_alert' | 'regulation_change'
|
import { TimelineEvent } from './_components/TimelineEvent'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MonitoringPage() {
|
export default function MonitoringPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const projectId = params.projectId as string
|
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(() => {
|
const {
|
||||||
fetchEvents()
|
events, loading, showForm, resolvingEvent,
|
||||||
}, [projectId])
|
filterType, filterStatus, filteredEvents,
|
||||||
|
openCount, resolvedCount,
|
||||||
async function fetchEvents() {
|
setShowForm, setResolvingEvent, setFilterType, setFilterStatus,
|
||||||
try {
|
handleSubmit, handleResolve,
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/monitoring`)
|
} = useMonitoring(projectId)
|
||||||
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
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user