feat: A/B Testing + Compliance Report PDF (F5 + F8)
F5: A/B Testing for Consent Rate - Migration 116: banner_variants table + variant tracking in audit log - BannerABService: deterministic sticky bucketing via device hash, chi-squared significance testing, variant CRUD - banner_ab_routes: 6 endpoints (CRUD + stats + assign) - ABTestPanel.tsx: variant creation, traffic sliders, opt-in comparison chart with winner/significance badges - New "A/B-Test" tab in cookie-banner page F8: Compliance Report PDF - CompliancePDFGenerator: reportlab-based A4 PDF covering all modules (Company Profile, TOM, VVT, DSFA, Risks, Vendors, Incidents, Reviews, Consents, Roles) - compliance_report_routes: GET /compliance/report/pdf - "Compliance-Report herunterladen" button on SDK dashboard [migration-approved] Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface Variant {
|
||||
id: string
|
||||
variant_name: string
|
||||
variant_key: string
|
||||
traffic_percent: number
|
||||
is_control: boolean
|
||||
banner_title: string | null
|
||||
banner_description: string | null
|
||||
position: string | null
|
||||
primary_color: string | null
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
interface VariantStat {
|
||||
variant_id: string
|
||||
variant_key: string
|
||||
variant_name: string
|
||||
traffic_percent: number
|
||||
is_control: boolean
|
||||
total: number
|
||||
accepted: number
|
||||
opt_in_rate: number
|
||||
is_winner?: boolean
|
||||
significance?: number
|
||||
}
|
||||
|
||||
const API = '/api/sdk/v1/compliance/banner/ab'
|
||||
|
||||
export function ABTestPanel({ siteConfigId }: { siteConfigId?: string }) {
|
||||
const [variants, setVariants] = useState<Variant[]>([])
|
||||
const [stats, setStats] = useState<VariantStat[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newVariant, setNewVariant] = useState({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
|
||||
|
||||
const scid = siteConfigId || ''
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!scid) { setLoading(false); return }
|
||||
setLoading(true)
|
||||
try {
|
||||
const [v, s] = await Promise.all([
|
||||
fetch(`${API}/${scid}/variants`).then(r => r.ok ? r.json() : []),
|
||||
fetch(`${API}/${scid}/stats`).then(r => r.ok ? r.json() : []),
|
||||
])
|
||||
setVariants(v)
|
||||
setStats(s)
|
||||
} catch { /* ignore */ }
|
||||
setLoading(false)
|
||||
}, [scid])
|
||||
|
||||
useEffect(() => { loadData() }, [loadData])
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!scid || !newVariant.variant_name) return
|
||||
await fetch(`${API}/${scid}/variants`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newVariant),
|
||||
})
|
||||
setShowCreate(false)
|
||||
setNewVariant({ variant_name: '', variant_key: 'B', traffic_percent: 50, banner_title: '', primary_color: '' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await fetch(`${API}/variants/${id}`, { method: 'DELETE' })
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleTrafficChange = async (id: string, pct: number) => {
|
||||
await fetch(`${API}/variants/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ traffic_percent: pct }),
|
||||
})
|
||||
loadData()
|
||||
}
|
||||
|
||||
if (!scid) {
|
||||
return <div className="text-center py-8 text-gray-400">Bitte waehlen Sie zuerst eine Site aus.</div>
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-center py-8 text-gray-400">Lade A/B-Test...</div>
|
||||
|
||||
const maxRate = Math.max(...stats.map(s => s.opt_in_rate), 1)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">A/B-Test Varianten</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Testen Sie verschiedene Banner-Konfigurationen um die Opt-In-Rate zu optimieren.</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(!showCreate)}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Variante erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreate && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input value={newVariant.variant_name} onChange={e => setNewVariant({ ...newVariant, variant_name: e.target.value })}
|
||||
placeholder="Name (z.B. Variante B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||
<input value={newVariant.variant_key} onChange={e => setNewVariant({ ...newVariant, variant_key: e.target.value })}
|
||||
placeholder="Key (z.B. B)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||
<input value={newVariant.banner_title} onChange={e => setNewVariant({ ...newVariant, banner_title: e.target.value })}
|
||||
placeholder="Banner-Titel (Override)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
|
||||
<input value={newVariant.primary_color} onChange={e => setNewVariant({ ...newVariant, primary_color: e.target.value })}
|
||||
placeholder="Farbe (z.B. #22c55e)" type="color" className="px-3 py-2 h-10 text-sm border border-gray-200 rounded-lg" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-600">Traffic:</label>
|
||||
<input type="range" min={5} max={95} value={newVariant.traffic_percent}
|
||||
onChange={e => setNewVariant({ ...newVariant, traffic_percent: parseInt(e.target.value) })}
|
||||
className="flex-1" />
|
||||
<span className="text-sm font-medium w-12 text-right">{newVariant.traffic_percent}%</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleCreate} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Erstellen</button>
|
||||
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-sm text-gray-500 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variants + Stats */}
|
||||
{variants.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white border border-gray-200 rounded-xl">
|
||||
<p className="text-gray-400">Kein A/B-Test aktiv.</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Erstellen Sie mindestens 2 Varianten um einen Test zu starten.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Comparison Chart */}
|
||||
{stats.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<h4 className="font-medium text-gray-900 mb-4">Opt-In-Rate Vergleich</h4>
|
||||
<div className="space-y-3">
|
||||
{stats.map(s => (
|
||||
<div key={s.variant_key} className="flex items-center gap-4">
|
||||
<div className="w-24 text-sm text-gray-700 truncate">
|
||||
{s.variant_name}
|
||||
{s.is_control && <span className="ml-1 text-[10px] text-gray-400">(Kontrolle)</span>}
|
||||
</div>
|
||||
<div className="flex-1 h-8 bg-gray-100 rounded-lg overflow-hidden relative">
|
||||
<div className={`h-full rounded-lg transition-all ${s.is_winner ? 'bg-green-500' : s.is_control ? 'bg-gray-400' : 'bg-purple-500'}`}
|
||||
style={{ width: `${(s.opt_in_rate / maxRate) * 100}%` }} />
|
||||
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-gray-900">
|
||||
{s.opt_in_rate}% ({s.accepted}/{s.total})
|
||||
</span>
|
||||
</div>
|
||||
{s.is_winner && (
|
||||
<span className="px-2 py-0.5 text-[10px] font-medium bg-green-100 text-green-700 rounded-full">
|
||||
Gewinner ({s.significance}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variant Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{variants.map(v => (
|
||||
<div key={v.id} className={`bg-white border rounded-xl p-4 ${v.is_control ? 'border-gray-300' : 'border-purple-200'}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-gray-900">{v.variant_name}</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded">{v.variant_key}</span>
|
||||
{v.is_control && <span className="px-1.5 py-0.5 text-[10px] bg-blue-50 text-blue-600 rounded">Kontrolle</span>}
|
||||
</div>
|
||||
<button onClick={() => handleDelete(v.id)} className="text-xs text-red-500 hover:text-red-700">Loeschen</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<label className="text-xs text-gray-500">Traffic:</label>
|
||||
<input type="range" min={5} max={95} value={v.traffic_percent}
|
||||
onChange={e => handleTrafficChange(v.id, parseInt(e.target.value))}
|
||||
className="flex-1 h-1" />
|
||||
<span className="text-xs font-medium w-8 text-right">{v.traffic_percent}%</span>
|
||||
</div>
|
||||
{v.banner_title && <div className="text-xs text-gray-500">Titel: {v.banner_title}</div>}
|
||||
{v.primary_color && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<div className="w-3 h-3 rounded-full border" style={{ backgroundColor: v.primary_color }} />
|
||||
<span className="text-xs text-gray-500">{v.primary_color}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,9 @@ import { VendorTable } from './_components/VendorTable'
|
||||
import { EmbeddableVendorHTML } from './_components/EmbeddableVendorHTML'
|
||||
import { SiteSelector } from './_components/SiteSelector'
|
||||
import { AnalyticsDashboard } from './_components/AnalyticsDashboard'
|
||||
import { ABTestPanel } from './_components/ABTestPanel'
|
||||
|
||||
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics'
|
||||
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' | 'abtest'
|
||||
|
||||
export default function CookieBannerPage() {
|
||||
const { state } = useSDK()
|
||||
@@ -77,6 +78,7 @@ export default function CookieBannerPage() {
|
||||
{ id: 'vendors' as const, label: 'Verarbeiter' },
|
||||
{ id: 'embed' as const, label: 'Einbettung' },
|
||||
{ id: 'analytics' as const, label: 'Analytik' },
|
||||
{ id: 'abtest' as const, label: 'A/B-Test' },
|
||||
]).map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
@@ -96,6 +98,9 @@ export default function CookieBannerPage() {
|
||||
{/* Tab: Analytik */}
|
||||
{activeTab === 'analytics' && <AnalyticsDashboard siteId={activeSiteId || undefined} />}
|
||||
|
||||
{/* Tab: A/B-Test */}
|
||||
{activeTab === 'abtest' && <ABTestPanel siteConfigId={activeSiteId || undefined} />}
|
||||
|
||||
{/* Tab: Konfiguration */}
|
||||
{activeTab !== 'config' ? null : (<>
|
||||
{/* Stats */}
|
||||
|
||||
@@ -222,6 +222,24 @@ export default function SDKDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compliance Report Download */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 border border-purple-200 rounded-xl p-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Compliance-Report</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">Umfassender PDF-Bericht ueber alle Module, Rollen, Risiken und Massnahmen.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const url = `/api/sdk/v1/compliance/report/pdf${projectId ? `?project_id=${projectId}` : ''}`
|
||||
window.open(url, '_blank')
|
||||
}}
|
||||
className="px-5 py-2.5 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
PDF herunterladen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
{state.commandBarHistory.length > 0 && (
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user