Files
breakpilot-compliance/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx
T
Benjamin Admin 3332eb0bf9 feat(agent): Cookie-Result-View + Check-Historie aus Snapshots
Snapshot-getriebene Result-Views, entkoppelt vom Live-Check:
- CookieResultView: laedt cmp_vendors aus einem Snapshot (kein Re-Crawl),
  KPIs (Anbieter/Cookies/Marketing/Drittland) + Empfaenger-Gruppen
  (Eigene/AVV/Joint-Controller) + aufklappbare Vendor->Cookie-Tabelle.
- Historie (/sdk/agent/snapshots): alle gespeicherten Checks, jederzeit
  oeffnbar (DSB/Mitarbeiter) + Detail-Seite je Snapshot.
- Next.js-Proxys fuer GET /snapshots (Liste) + /snapshots/{id} (einzeln).

BMW-Snapshot 4603d15b: 83 Vendors / 780 Cookies. Library-Abgleich
(cookie_knowledge_db.lookup_cookie) folgt als Phase B.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 00:51:25 +02:00

178 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
/**
* CookieResultView — strukturierte Cookie-/Vendor-Auswertung aus einem
* gespeicherten Snapshot (cmp_vendors), OHNE Re-Crawl.
*
* KPIs + Empfänger-Gruppen (Eigene / Auftragsverarbeiter / Joint Controller —
* wie im Audit-Mail-VVT) + aufklappbare Vendor→Cookie-Tabelle. Verarbeitet
* Mengen (780 Cookies bei BMW): Vendors gruppiert, Cookies on-demand.
*/
import React, { useMemo, useState } from 'react'
export interface SnapshotCookie {
name: string
expiry?: string
purpose?: string
is_third_party?: boolean
functional_role?: string
}
export interface SnapshotVendor {
name: string
cookies?: SnapshotCookie[]
category?: string
country?: string
recipient_type?: string
compliance_score?: number
compliance_flags?: string[]
opt_out_ok?: boolean
}
interface Snapshot {
id: string
site_domain?: string
created_at?: string
cmp_vendors?: SnapshotVendor[]
}
const ROLE_LABEL: Record<string, string> = {
unknown: 'Unbekannt', ad_pixel: 'Werbe-Pixel', auth_token: 'Auth-Token',
preference: 'Präferenz', visitor_id: 'Besucher-ID', consent_state: 'Consent',
tracking: 'Tracking',
}
const CAT_COLOR: Record<string, string> = {
necessary: 'bg-green-100 text-green-700', functional: 'bg-blue-100 text-blue-700',
statistics: 'bg-amber-100 text-amber-700', marketing: 'bg-red-100 text-red-700',
}
const EEA = new Set([
'DE','FR','IE','NL','AT','BE','BG','HR','CY','CZ','DK','EE','FI','GR','HU',
'IT','LV','LT','LU','MT','PL','PT','RO','SK','SI','ES','SE','IS','LI','NO',
])
const GROUPS = [
{ key: 'own', label: 'Eigene Verarbeitungen (VVT, Art. 30)', test: (r: string) => !r || r === 'INTERNAL' || r === 'GROUP' || r === 'CONTROLLER' },
{ key: 'proc', label: 'Auftragsverarbeiter (AVV, Art. 28)', test: (r: string) => r === 'PROCESSOR' },
{ key: 'joint', label: 'Joint Controller (Art. 26)', test: (r: string) => r === 'JOINT_CONTROLLER' },
{ key: 'other', label: 'Sonstige Empfänger', test: () => true },
]
function scoreColor(s?: number): string {
if (s == null) return 'text-gray-400'
return s >= 80 ? 'text-green-700' : s >= 50 ? 'text-amber-700' : 'text-red-700'
}
function Tile({ label, value, tone }: { label: string; value: React.ReactNode; tone: string }) {
return (
<div className="border border-gray-200 rounded-lg p-3 bg-white">
<div className={`text-2xl font-semibold leading-none ${tone}`}>{value}</div>
<div className="text-xs text-gray-500 mt-1.5">{label}</div>
</div>
)
}
function VendorRow({ v }: { v: SnapshotVendor }) {
const [open, setOpen] = useState(false)
const cookies = v.cookies || []
const cat = (v.category || '').toLowerCase()
const drittland = !!v.country && !EEA.has((v.country || '').toUpperCase())
return (
<div>
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-gray-50 text-xs"
>
<span className={`text-gray-400 transition-transform ${open ? 'rotate-90' : ''}`}></span>
<span className="font-medium text-gray-800 flex-1 min-w-0 truncate">{v.name}</span>
{cat && (
<span className={`px-1.5 py-0.5 rounded text-[10px] ${CAT_COLOR[cat] || 'bg-gray-100 text-gray-600'}`}>
{v.category}
</span>
)}
{drittland && (
<span className="px-1.5 py-0.5 rounded text-[10px] bg-red-50 text-red-600" title="außerhalb EWR">
{v.country}
</span>
)}
<span className="text-gray-500 w-16 text-right">{cookies.length} Cookies</span>
<span className={`w-10 text-right font-semibold ${scoreColor(v.compliance_score)}`}>
{v.compliance_score != null ? `${v.compliance_score}%` : '—'}
</span>
</button>
{open && cookies.length > 0 && (
<div className="ml-6 mb-1 border-l-2 border-gray-200">
<table className="w-full text-[11px]">
<thead className="text-gray-400">
<tr>
<th className="px-2 py-1 text-left font-normal">Cookie</th>
<th className="px-2 py-1 text-left font-normal">Rolle</th>
<th className="px-2 py-1 text-left font-normal">Zweck</th>
<th className="px-2 py-1 text-left font-normal">Laufzeit</th>
</tr>
</thead>
<tbody>
{cookies.map((c, i) => (
<tr key={i} className="border-t border-gray-100">
<td className="px-2 py-1 font-mono text-gray-700 break-all">{c.name}</td>
<td className="px-2 py-1 text-gray-500">{ROLE_LABEL[c.functional_role || 'unknown'] || c.functional_role}</td>
<td className="px-2 py-1 text-gray-500">{c.purpose ? c.purpose.slice(0, 60) : <span className="text-amber-600 italic">kein Zweck</span>}</td>
<td className="px-2 py-1 text-gray-400">{c.expiry || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
export function CookieResultView({ snapshot }: { snapshot: Snapshot }) {
const vendors = snapshot.cmp_vendors || []
const stats = useMemo(() => {
const cookies = vendors.reduce((n, v) => n + (v.cookies?.length || 0), 0)
const marketing = vendors.filter(v => (v.category || '').toLowerCase() === 'marketing').length
const drittland = vendors.filter(v => v.country && !EEA.has(v.country.toUpperCase())).length
return { cookies, marketing, drittland }
}, [vendors])
const grouped = useMemo(() => GROUPS.map(g => ({
...g,
vendors: vendors
.filter(v => GROUPS.find(gg => gg.test((v.recipient_type || '').toUpperCase()))?.key === g.key)
.sort((a, b) => (a.compliance_score ?? 100) - (b.compliance_score ?? 100)),
})).filter(g => g.vendors.length > 0), [vendors])
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">
Cookie-Auswertung {snapshot.site_domain || 'Snapshot'}
</h2>
<p className="text-xs text-gray-500 mt-0.5">
aus gespeichertem Snapshot (kein Re-Crawl) ·{' '}
{snapshot.created_at ? snapshot.created_at.slice(0, 19).replace('T', ' ') : ''}
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Tile label="Anbieter" value={vendors.length} tone="text-gray-800" />
<Tile label="Cookies gesamt" value={stats.cookies} tone="text-gray-800" />
<Tile label="Marketing-Anbieter" value={stats.marketing} tone={stats.marketing > 0 ? 'text-red-700' : 'text-gray-800'} />
<Tile label="Drittland (außerhalb EWR)" value={stats.drittland} tone={stats.drittland > 0 ? 'text-amber-700' : 'text-gray-800'} />
</div>
{grouped.map(g => (
<div key={g.key} className="border rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-slate-50 border-b text-xs font-semibold text-gray-700">
{g.label} <span className="text-gray-400 font-normal">({g.vendors.length})</span>
</div>
<div className="divide-y divide-gray-100">
{g.vendors.map((v, i) => <VendorRow key={i} v={v} />)}
</div>
</div>
))}
</div>
)
}