Files
breakpilot-compliance/admin-compliance/app/sdk/agent/_components/MigrationPanel.tsx
T
Benjamin Admin df7d83134b feat(agent): migrate compliance-check results to banner + documents (M1-M5)
After a compliance-check run finishes, the user can now apply the
extracted vendor inventory directly to their own:

  - CookieBanner config (admin /sdk/einwilligungen)
  - Cookie-Policy / VVT-Register / Privacy-Policy templates
    (admin /sdk/document-generator)

Backend:
  - migration_to_banner.py: vendor list -> CookieBannerConfig with
    ESSENTIAL/PERFORMANCE/PERSONALIZATION/EXTERNAL_MEDIA buckets +
    review flags (broken opt-out URLs, missing expiry, no cookies listed)
  - migration_to_document.py: vendor list -> pre-fills for 3 doc
    templates, recipient-type aware (INTERNAL/GROUP/PROCESSOR/CONTROLLER)
  - agent_migration_routes.py: GET /banner-preview, /document-preview,
    /summary keyed on check_id
  - compliance_audit_log: new check_payloads table persists cmp_vendors +
    extracted_profile so the preview survives an app restart
  - tests: 9 mapper units + 4 endpoint integration tests

Frontend:
  - MigrationPanel.tsx: modal showing banner-config diff + document
    pre-fills, plus links into the existing editors
  - ComplianceCheckTab.tsx: replaces standalone audit link with the
    panel; net -3 lines, stays at the 500-cap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:06:28 +02:00

195 lines
7.1 KiB
TypeScript

'use client'
import { useState } from 'react'
interface BannerFlag {
level: 'ERROR' | 'WARNING' | 'INFO'
vendor: string
issue: string
message: string
}
interface BannerPreview {
config: { categories: { id: string; cookies: { name: string }[] }[] }
flags: BannerFlag[]
summary: {
vendors_total: number
vendors_with_no_cookies: number
cookies_total: number
categories: Record<string, number>
flags_error: number
flags_warning: number
flags_info: number
}
}
interface DocumentPreview {
check_id: string
vendor_count: number
templates: Record<string, {
templateType: string
initialContent: string
suggested_template_search?: string
}>
}
type Mode = 'banner' | 'documents'
export function MigrationPanel({ checkId }: { checkId: string }) {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<Mode>('banner')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [banner, setBanner] = useState<BannerPreview | null>(null)
const [docs, setDocs] = useState<DocumentPreview | null>(null)
async function loadPreview(next: Mode) {
setMode(next)
setOpen(true)
setError(null)
setLoading(true)
try {
const path = next === 'banner'
? `/api/sdk/v1/agent/migration/${checkId}/banner-preview`
: `/api/sdk/v1/agent/migration/${checkId}/document-preview`
const r = await fetch(path)
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json()
if (next === 'banner') setBanner(data)
else setDocs(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Preview-Ladefehler')
} finally {
setLoading(false)
}
}
return (
<>
<div className="mt-3 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<button onClick={() => loadPreview('banner')}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100">
Cookie-Banner uebernehmen
</button>
<button onClick={() => loadPreview('documents')}
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100">
Dokumente vorbefuellen
</button>
</div>
<a href={`/sdk/agent/audit/${checkId}`} target="_blank" rel="noopener"
className="text-xs text-blue-700 hover:text-blue-900 underline">
Voll-Audit oeffnen (alle MCs) &rarr;
</a>
</div>
{open && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-start justify-center p-6 overflow-y-auto">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl p-6 mt-12">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">
{mode === 'banner' ? 'Cookie-Banner Migration' : 'Dokument-Vorbefuellung'}
</h3>
<button onClick={() => setOpen(false)}
className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
{loading && <div className="text-sm text-gray-500">Lade Preview ...</div>}
{error && <div className="text-sm text-red-600">Fehler: {error}</div>}
{!loading && !error && mode === 'banner' && banner && (
<BannerPreviewBody data={banner} />
)}
{!loading && !error && mode === 'documents' && docs && (
<DocumentPreviewBody data={docs} />
)}
<div className="mt-5 flex justify-end gap-2">
<button onClick={() => setOpen(false)}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">
Schliessen
</button>
<a href={mode === 'banner' ? '/sdk/einwilligungen' : '/sdk/document-generator'}
className="px-3 py-1.5 text-sm rounded-lg bg-purple-600 text-white hover:bg-purple-700">
Im Editor oeffnen
</a>
</div>
</div>
</div>
)}
</>
)
}
function BannerPreviewBody({ data }: { data: BannerPreview }) {
const { summary, flags, config } = data
return (
<div className="space-y-4 text-sm">
<div className="grid grid-cols-3 gap-3">
<Stat label="Anbieter" value={summary.vendors_total} />
<Stat label="Cookies" value={summary.cookies_total} />
<Stat label="Kategorien" value={Object.values(summary.categories).filter(n => n > 0).length} />
</div>
<div className="grid grid-cols-3 gap-3">
<Stat label="Fehler" value={summary.flags_error} tone="red" />
<Stat label="Warnungen" value={summary.flags_warning} tone="amber" />
<Stat label="Hinweise" value={summary.flags_info} tone="gray" />
</div>
<div>
<h4 className="font-medium text-gray-700 mb-1">Kategorien</h4>
<ul className="text-xs text-gray-600 space-y-0.5">
{config.categories.map(c => (
<li key={c.id}>{c.id}: {c.cookies.length} Cookie(s)</li>
))}
</ul>
</div>
{flags.length > 0 && (
<div>
<h4 className="font-medium text-gray-700 mb-1">Pruefpunkte</h4>
<ul className="text-xs space-y-0.5 max-h-48 overflow-y-auto">
{flags.map((f, i) => (
<li key={i} className={f.level === 'ERROR' ? 'text-red-700' : f.level === 'WARNING' ? 'text-amber-700' : 'text-gray-600'}>
[{f.level}] {f.vendor}: {f.message}
</li>
))}
</ul>
</div>
)}
</div>
)
}
function DocumentPreviewBody({ data }: { data: DocumentPreview }) {
return (
<div className="space-y-4 text-sm">
<div className="text-xs text-gray-600">
{data.vendor_count} Anbieter werden in {Object.keys(data.templates).length} Vorlagen eingespielt.
</div>
{Object.entries(data.templates).map(([key, tpl]) => (
<div key={key} className="border border-gray-200 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium text-gray-800">{tpl.templateType}</h4>
{tpl.suggested_template_search && (
<span className="text-xs text-gray-500">Vorschlag: {tpl.suggested_template_search}</span>
)}
</div>
<pre className="text-xs bg-gray-50 rounded p-2 max-h-48 overflow-auto whitespace-pre-wrap">
{tpl.initialContent.slice(0, 1200)}{tpl.initialContent.length > 1200 ? '\n…' : ''}
</pre>
</div>
))}
</div>
)
}
function Stat({ label, value, tone = 'gray' }: { label: string; value: number; tone?: 'red' | 'amber' | 'gray' }) {
const color = tone === 'red' ? 'text-red-700' : tone === 'amber' ? 'text-amber-700' : 'text-gray-800'
return (
<div className="border border-gray-200 rounded-lg p-2 text-center">
<div className={`text-lg font-semibold ${color}`}>{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
)
}