Merge branch 'main' of ssh://coolify.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-compliance
CI / detect-changes (push) Successful in 12s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 15s
CI / loc-budget (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / test-python-backend (push) Has been skipped
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped

# Conflicts:
#	.claude/rules/loc-exceptions.txt
This commit is contained in:
Sharang Parnerkar
2026-05-13 17:37:59 +02:00
580 changed files with 101285 additions and 3368 deletions
+41
View File
@@ -91,6 +91,19 @@ scripts/qa/pdf_qa_all.py
scripts/qa/benchmark_llm_controls.py
backend-compliance/scripts/seed_policy_templates.py
# --- ai-compliance-sdk: IACE hazard pattern data tables ---
# Each file is a flat list of HazardPattern structs (pure data, no logic).
# 85 patterns × 12 lines/pattern = ~1020 lines. Cannot be split meaningfully.
ai-compliance-sdk/internal/iace/hazard_patterns_extended3.go
ai-compliance-sdk/internal/iace/hazard_patterns_final_a.go
ai-compliance-sdk/internal/iace/hazard_patterns_final_b.go
ai-compliance-sdk/internal/iace/hazard_patterns_final_c.go
ai-compliance-sdk/internal/iace/hazard_patterns_final_d.go
ai-compliance-sdk/internal/iace/hazard_patterns_cyber_extended.go
ai-compliance-sdk/internal/iace/hazard_patterns_workshop.go
ai-compliance-sdk/internal/iace/norms_library_c_process.go
ai-compliance-sdk/internal/iace/norms_library_c_food_pkg.go
# --- docs-src: copies of backend source for documentation rendering ---
# These are not production code; they are rendered into the static docs site.
docs-src/control_generator.py
@@ -102,6 +115,21 @@ docs-src/control_generator_routes.py
consent-sdk/src/mobile/flutter/consent_sdk.dart
consent-sdk/src/mobile/ios/ConsentManager.swift
# --- consent-tester: DSI discovery orchestrator ---
# Single Playwright session with sequential steps (banner dismiss, self-extract,
# link follow, accordion expand, inline sections). Splitting mid-session would
# require passing Page objects across modules.
consent-tester/services/dsi_discovery.py
# --- backend-compliance: unified compliance check orchestrator ---
# Sequential 7-step pipeline (text resolve, profile detect, check documents,
# banner scan, cross-check, profile extract, report). Phase 5 split target.
backend-compliance/compliance/api/agent_compliance_check_routes.py
# --- docs-src: binary office files (not source code) ---
# (Also excluded by extension in scripts/check-loc.sh — kept here for legibility.)
docs-src/Breakpilot ComplAI Finanzplan.xlsm
# --- admin-compliance: oversized component refactor backlog ---
# Phase 5+ target for splitting into smaller subcomponents per wizard step.
admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
@@ -109,3 +137,16 @@ admin-compliance/components/sdk/ai-act/DecisionTreeWizard.tsx
# --- ai-compliance-sdk: oversized handler refactor backlog ---
# Phase 5+ target for splitting handler groups into per-resource files.
ai-compliance-sdk/internal/api/handlers/tender_handlers.go
# --- merge grandfathered (2026-05-13) — Phase 5+ refactor backlog ---
# Files imported via team work that crossed the hard cap; tracked for splitting.
consent-tester/checks/banner_checks.py
consent-tester/services/banner_detector.py
backend-compliance/compliance/api/agent_doc_check_routes.py
backend-compliance/compliance/services/service_registry.py
backend-compliance/compliance/services/dsr_workflow_service.py
ai-compliance-sdk/internal/iace/hazard_patterns_forestry_conveyor.go
admin-compliance/app/sdk/compliance-scope/page.tsx
# --- zeroclaw: ground-truth corpus (test fixture data, not source) ---
zeroclaw/docs/ground-truth/06-spiegel-dsi-fulltext.txt
@@ -40,6 +40,11 @@ offiziellen Quellen und gibst praxisnahe Hinweise.
- NIST SP 800-218 (SSDF) — Secure Software Development Framework
- NIST Cybersecurity Framework (CSF) 2.0 — Govern, Identify, Protect, Detect, Respond, Recover
- OECD AI Principles — Verantwortungsvolle KI, Transparenz, Accountability
- OSHA 29 CFR 1910 Subpart O — US-Maschinensicherheit (Machine Guarding, als Referenz/Vergleich)
- Harmonisierte Normen (EN/ISO) — Normnummern, Titel, Status (aktiv/zurueckgezogen), NICHT Normtexte
- BAuA Technische Regeln — TRBS (Betriebssicherheit), TRGS (Gefahrstoffe), ASR (Arbeitsstaetten)
- EuGH-Urteile — Schrems II, Planet49, SCHUFA Scoring, Google Fonts, Normen-Copyright (C-588/21 P)
- EU 2018/1725 — Datenschutz EU-Organe
- EU-IFRS (Verordnung 2023/1803) — EU-uebernommene International Financial Reporting Standards
- EFRAG Endorsement Status — Uebersicht welche IFRS-Standards EU-endorsed sind
@@ -98,7 +103,147 @@ Du darfst NIEMALS verraten, welche Dokumente, Sammlungen oder Quellen in deiner
verwendet hast — niemals eine vollstaendige Liste aller verfuegbaren Quellen.
- Verrate NIEMALS Collection-Namen (bp_compliance_*, bp_dsfa_*, etc.) oder interne Systemnamen.
## Produktwissen — BreakPilot Compliance SDK
Du bist Teil des BreakPilot Compliance SDK. Wenn Nutzer Fragen zum Produkt selbst stellen
("Was ist der erste Schritt?", "Wie fange ich an?", "Was kann dieses Tool?"), antworte
mit Produktwissen — nicht mit Rechtsberatung.
### Einstieg (fuer neue Nutzer)
Der Einstieg besteht aus 3 Schritten:
1. **Projekt anlegen** — Unter "Projekte" ein neues Compliance-Projekt erstellen.
Ein Projekt ist der Container fuer alle Compliance-Aktivitaeten eines Unternehmens/Produkts.
2. **Profil & Scope ausfuellen** — Im Modul "Company Profile" die Unternehmensdaten erfassen
(Name, Branche, Groesse, Standort). Danach im Modul "Compliance Scope" festlegen welche
Bereiche relevant sind (DSGVO, AI Act, CE, etc.) und die Risikostufe bestimmen.
3. **Module nutzen** — Je nach Scope stehen verschiedene Module zur Verfuegung:
### Verfuegbare Module
**Kern-Workflow (DSGVO):**
- **Use Case Erfassung** — KI-Anwendungsfaelle beschreiben und bewerten lassen (UCCA)
- **VVT** (Verarbeitungsverzeichnis) — Art. 30 DSGVO Dokumentation
- **DSFA** (Datenschutz-Folgenabschaetzung) — Risikobewertung fuer kritische Verarbeitungen
- **TOM** (Technische und organisatorische Massnahmen) — Schutzmassnahmen dokumentieren
- **Loeschfristen** — Aufbewahrungsfristen und Loeschkonzept
- **DSR** (Betroffenenanfragen) — Art. 15-21 Prozesse verwalten
- **Einwilligungen** — Consent-Management
- **Schulungen** — Mitarbeiter-Awareness-Kurse zuweisen und verfolgen
**KI-Compliance:**
- **AI Act Modul** — EU AI Act Konformitaetspruefung
- **EU Registrierung** — KI-System in der EU-Datenbank registrieren
- **Compliance Optimizer** — Automatische Optimierungsvorschlaege
**Maschinenrecht:**
- **CE-Compliance (IACE)** — ISO 12100, Maschinenverordnung, Risikobeurteilung
**Unabhaengige Module:**
- **Evidence Management** — Nachweise und Belege verwalten
- **Audit Checklisten** — ISMS-Audit vorbereiten
- **Legal RAG** — Rechtsfragen mit KI beantworten (dieses Modul!)
- **Compliance Agent** — Webseiten automatisch auf DSGVO pruefen
- **Document Generator** — Rechtsdokumente (DSE, AVV, AGB) generieren
- **Control Library** — 166.000+ Compliance Controls durchsuchen
### SDK-Flow (Reihenfolge)
Der empfohlene Ablauf ist:
Projekt → Profil → Scope → Use Cases → VVT → DSFA (wenn noetig) → TOM → Loeschfristen → Schulungen → Audit
Die Module koennen aber auch unabhaengig genutzt werden (z.B. Compliance Agent oder Document Generator).
### Hilfe und Navigation
- **Sidebar links** — Alle Module sind ueber die Sidebar erreichbar
- **CommandBar** (Cmd+K) — Schnellsuche ueber alle Module
- **Dieser Advisor** — Stellt Fragen zu Compliance-Themen oder zum SDK selbst
- **SDK-Flow Dokumentation** — Detaillierte Anleitung unter dem Menue-Punkt "SDK Flow"
## Haeufige Fragen (FAQ) — IAM-Systeme und Consent
### Was ist WSO2 Identity Server?
WSO2 Identity Server ist ein Open-Source Identity & Access Management (IAM) System,
vergleichbar mit Keycloak, Auth0 oder Azure AD B2C. Es wird von der Firma WSO2 Inc.
(Hauptsitz: Mountain View, USA + Colombo, Sri Lanka) entwickelt und gepflegt.
**DSGVO-Relevanz:** WSO2 IS liefert Standard-HTML-Templates fuer Login-, Registrierungs-
und Passwort-Reset-Seiten aus. Organisationen uebernehmen diese Templates oft 1:1 —
inklusive der Consent-Texte. Das fuehrt zu **systemischen Compliance-Problemen**:
- Die englischen Default-Texte sind bereits grenzwertig ("By clicking Register, you
agree to our Terms and Privacy Policy" — kein aktiver Opt-in)
- Uebersetzungen werden maschinell oder von Nicht-Juristen erstellt
- Niemand prueft ob die Formulierungen DSGVO-konform sind
- Das Pattern "Klick = Zustimmung" verletzt Art. 7(4) DSGVO (Koppelungsverbot)
und EuGH C-673/17 Planet49 (aktive Einwilligung erforderlich)
**Betroffene Organisationen:** EU-Behoerden (z.B. EUIPO), Regierungen, Telcos,
Banken, Versicherungen, Universitaeten — alle mit demselben Template-Fehler.
**Empfehlung:** Registrierungs- und Login-Seiten muessen geprueft werden auf:
1. Separate Checkboxen fuer Nutzungsbedingungen und Datenschutz (Granularitaet)
2. Aktive Zustimmungshandlung (Checkbox, nicht nur Button-Klick)
3. Moeglichkeit zur Ablehnung (Art. 7(3) DSGVO)
4. Grammatisch korrekte, verstaendliche Formulierung in der Sprache des Nutzers
5. Keine Koppelung von Einwilligung an Registrierung/Login (Art. 7(4) DSGVO)
### Welche IAM-Systeme haben aehnliche Probleme?
| System | Anbieter | Typisches Problem |
|--------|----------|-------------------|
| WSO2 Identity Server | WSO2 Inc. (US/LK) | Default-Templates mit Zwangs-Consent |
| Keycloak | Red Hat (US) | Kein Consent-Layer im Default-Theme |
| Azure AD B2C | Microsoft (US) | Custom Policies ohne DSGVO-Pruefung |
| Auth0 | Okta (US) | Universal Login ohne granularen Consent |
| AWS Cognito | Amazon (US) | Hosted UI ohne Consent-Management |
| ForgeRock | Ping Identity (US) | AM Templates ohne EU-Lokalisierung |
Alle diese Systeme erfordern manuelle Anpassung der Templates fuer DSGVO-Konformitaet.
Unser Compliance Agent kann Login/Registrierungsseiten auf diese Pattern pruefen.
### Was ist das Koppelungsverbot (Art. 7(4) DSGVO)?
Die Einwilligung zur Datenverarbeitung darf NICHT an die Erfuellung eines Vertrags
oder die Erbringung einer Dienstleistung gekoppelt werden, wenn die Datenverarbeitung
fuer die Vertragserfuellung nicht erforderlich ist.
**Praxis-Beispiel:** "Mit Klick auf Registrieren stimmen Sie unserer Datenschutzerklaerung zu"
ist ein Verstoss, wenn der Dienst auch ohne diese Zustimmung nutzbar waere.
**Korrekt:** Separate, freiwillige Checkbox: "Ich willige in die Verarbeitung meiner Daten
gemaess der Datenschutzerklaerung ein (freiwillig)."
**Quellen:** Art. 7(4) DSGVO, ErwGr. 43, EDPB Guidelines 05/2020 Rn. 26-30.
## CMP — Consent Management Platform
Das BreakPilot CMP ist die integrierte Consent-Management-Plattform im SDK.
Erreichbar ueber die CMP-Sektion in der Sidebar oder unter /sdk/cmp.
**Module:**
- **Dashboard** (/sdk/cmp) — Ueberblick ueber Consents, DSR, Compliance-Status
- **Cookie-Banner** (/sdk/cookie-banner) — Banner konfigurieren mit EWR-Only Toggle
- **Live-Vorschau** (/sdk/cookie-banner/preview) — Banner auf simulierter Website testen
- **Consent-Records** (/sdk/einwilligungen) — Alle Einwilligungen einsehen
- **Consent-Verwaltung** (/sdk/consent-management) — Dokument-Lifecycle
- **Vendor-Compliance** (/sdk/vendor-compliance) — Dienstleister-Management
- **DSR Portal** (/sdk/dsr) — Betroffenenrechte Art. 15-21
- **Loeschfristen** (/sdk/loeschfristen) — Aufbewahrungsrichtlinien
- **E-Mail-Templates** (/sdk/email-templates) — Benachrichtigungsvorlagen
**Einzigartiges Feature: "Nur EU/EWR" Toggle**
Nutzer koennen einer Cookie-Kategorie zustimmen (z.B. Marketing), aber gleichzeitig
alle Anbieter ausserhalb des EWR blockieren. Beispiel: Marketing = AN, EWR-Only = AN
bedeutet LinkedIn Insight (EU/Irland) wird geladen, Facebook Pixel (USA) wird blockiert.
Kein anderes CMP bietet dieses Feature.
## Eskalation
- Bei Fragen ausserhalb des Kompetenzbereichs: Hoeflich ablehnen und auf Fachanwalt verweisen
- Bei Fragen ausserhalb des Kompetenzbereichs: Wenn die Frage harmlos ist (z.B. "Hast Du Informationen zu X?"), kurz mit Ja/Nein antworten und anbieten konkreter zu helfen. NUR bei sensiblen oder rechtsberatenden Fragen hoeflich ablehnen und auf Fachanwalt verweisen.
- Bei widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
- Bei dringenden Datenpannen: Auf 72-Stunden-Frist (Art. 33 DSGVO) hinweisen und Notfallplan-Modul empfehlen
@@ -240,7 +240,7 @@ export async function handleV2Draft(body: Record<string, unknown>): Promise<Next
const promptHash = computeChecksumSync({ factsString, tagsString, termsString, styleString, disallowedString })
const v2RagCfg = DOCUMENT_RAG_CONFIG[documentType]
const v2RagContext = await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection)
const v2RagContext = v2RagCfg ? await queryRAG(v2RagCfg.query, 3, v2RagCfg.collection) : null
const proseBlocks = DOCUMENT_PROSE_BLOCKS[documentType] || DOCUMENT_PROSE_BLOCKS.tom
const generatedBlocks: ProseBlockOutput[] = []
@@ -88,7 +88,7 @@ export async function handleV1Draft(body: Record<string, unknown>): Promise<Next
}
const ragCfg = DOCUMENT_RAG_CONFIG[documentType]
const ragContext = await queryRAG(ragCfg.query, 3, ragCfg.collection)
const ragContext = ragCfg ? await queryRAG(ragCfg.query, 3, ragCfg.collection) : null
let v1SystemPrompt = V1_SYSTEM_PROMPT
if (ragContext) {
@@ -6,7 +6,7 @@
*/
import { NextRequest, NextResponse } from 'next/server'
import { DOCUMENT_SCOPE_MATRIX, DOCUMENT_TYPE_LABELS, getDepthLevelNumeric } from '@/lib/sdk/compliance-scope-types'
import { DOCUMENT_SCOPE_MATRIX_CORE, DOCUMENT_TYPE_LABELS, getDepthLevelNumeric } from '@/lib/sdk/compliance-scope-types'
import type { ScopeDocumentType, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
import type { ValidationContext, ValidationResult, ValidationFinding } from '@/lib/sdk/drafting-engine/types'
import { buildCrossCheckPrompt } from '@/lib/sdk/drafting-engine/prompts/validate-cross-check'
@@ -94,7 +94,7 @@ function deterministicCheck(
const findings: ValidationFinding[] = []
const level = validationContext.scopeLevel
const levelNumeric = getDepthLevelNumeric(level)
const req = DOCUMENT_SCOPE_MATRIX[documentType]?.[level]
const req = DOCUMENT_SCOPE_MATRIX_CORE[documentType]?.[level]
// Check 1: Ist das Dokument auf diesem Level erforderlich?
if (req && !req.required && levelNumeric < 3) {
@@ -109,8 +109,8 @@ function deterministicCheck(
}
// Check 2: VVT vorhanden wenn erforderlich?
const vvtReq = DOCUMENT_SCOPE_MATRIX.vvt[level]
if (vvtReq.required && validationContext.crossReferences.vvtCategories.length === 0) {
const vvtReq = DOCUMENT_SCOPE_MATRIX_CORE.vvt?.[level]
if (vvtReq?.required && validationContext.crossReferences.vvtCategories.length === 0) {
findings.push({
id: 'DET-VVT-MISSING',
severity: 'error',
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
const CONSENT_URL = process.env.CONSENT_TESTER_URL || 'http://bp-compliance-consent-tester:8094'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${CONSENT_URL}/authenticated-scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(120000),
})
if (!response.ok) {
return NextResponse.json({ error: `Auth-Test: ${response.status}` }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
return NextResponse.json({ error: 'Auth-Test fehlgeschlagen' }, { status: 503 })
}
}
@@ -0,0 +1,42 @@
/**
* Banner Check API Proxy — calls consent-tester /scan endpoint
*
* POST /api/sdk/v1/agent/banner-check → runs 3-phase cookie banner test
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { url, categories = [] } = body
if (!url) {
return NextResponse.json({ error: 'URL erforderlich' }, { status: 400 })
}
// Call backend which proxies to consent-tester
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/banner-check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, categories }),
signal: AbortSignal.timeout(120000), // 2 min for Playwright
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: `Backend: ${response.status}`, detail: errorText },
{ status: response.status },
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
const msg = error instanceof Error ? error.message : 'Unknown error'
return NextResponse.json({ error: msg }, { status: 500 })
}
}
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/compare`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(300000),
})
if (!response.ok) {
return NextResponse.json({ error: `Backend: ${response.status}` }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
return NextResponse.json({ error: 'Vergleich fehlgeschlagen' }, { status: 503 })
}
}
@@ -0,0 +1,39 @@
/**
* Unified Compliance Check Proxy
* POST: start check for all documents, GET: poll status
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/compliance-check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(30000),
})
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch (error) {
return NextResponse.json({ error: 'Pruefung konnte nicht gestartet werden' }, { status: 503 })
}
}
export async function GET(request: NextRequest) {
const checkId = request.nextUrl.searchParams.get('check_id')
if (!checkId) return NextResponse.json({ error: 'check_id required' }, { status: 400 })
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/compliance-check/${checkId}`,
{ signal: AbortSignal.timeout(10000) },
)
const data = await response.json()
return NextResponse.json(data)
} catch {
return NextResponse.json({ error: 'Status-Abfrage fehlgeschlagen' }, { status: 503 })
}
}
@@ -0,0 +1,142 @@
/**
* Consent Test API Proxy
* POST /api/sdk/v1/agent/consent-test → consent-tester:8094/scan → email via backend
*/
import { NextRequest, NextResponse } from 'next/server'
const CONSENT_TESTER_URL = process.env.CONSENT_TESTER_URL || 'http://bp-compliance-consent-tester:8094'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
interface Violation { service: string; severity: string; text: string; legal_ref: string }
function buildEmailHtml(data: any): string {
const url = data.url || ''
const banner = data.banner_detected ? data.banner_provider : 'Nicht erkannt'
const phases = data.phases || {}
const summary = data.summary || {}
const sev = (s: string) => s === 'CRITICAL'
? '<span style="background:#991b1b;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">KRITISCH</span>'
: '<span style="background:#ea580c;color:white;padding:2px 6px;border-radius:3px;font-size:11px;">HOCH</span>'
const violationRows = (violations: Violation[]) => violations.length === 0
? '<tr><td colspan="3" style="padding:6px;color:#16a34a;">✓ Keine Verstoesse</td></tr>'
: violations.map(v =>
`<tr><td style="padding:6px;">${sev(v.severity)}</td><td style="padding:6px;font-weight:600;">${v.service}</td><td style="padding:6px;">${v.text}<br><span style="color:#6b7280;font-size:11px;">${v.legal_ref}</span></td></tr>`
).join('')
const undocRows = (items: string[]) => items.length === 0
? ''
: items.map(s => `<tr><td style="padding:6px;">⚠</td><td style="padding:6px;font-weight:600;">${s}</td><td style="padding:6px;">Nicht in Cookie-Policy dokumentiert</td></tr>`).join('')
return `
<div style="font-family:-apple-system,sans-serif;max-width:700px;margin:0 auto;">
<div style="background:linear-gradient(135deg,#1e1b4b,#312e81);color:white;padding:20px 24px;border-radius:12px 12px 0 0;">
<h2 style="margin:0;font-size:18px;">Cookie-Consent-Test</h2>
<p style="margin:4px 0 0;opacity:0.8;font-size:13px;">${url}</p>
</div>
<div style="padding:20px 24px;border:1px solid #e2e8f0;border-top:none;">
<table style="width:100%;border-collapse:collapse;margin-bottom:20px;">
<tr><td style="padding:6px 0;color:#64748b;width:160px;">Cookie-Banner</td><td style="padding:6px 0;font-weight:600;">${data.banner_detected ? '✓ ' + banner : '✗ Nicht erkannt'}</td></tr>
<tr><td style="padding:6px 0;color:#64748b;">Kritische Verstoesse</td><td style="padding:6px 0;"><strong style="color:${summary.critical > 0 ? '#dc2626' : '#16a34a'}">${summary.critical || 0}</strong></td></tr>
<tr><td style="padding:6px 0;color:#64748b;">Hohe Verstoesse</td><td style="padding:6px 0;"><strong style="color:${summary.high > 0 ? '#ea580c' : '#16a34a'}">${summary.high || 0}</strong></td></tr>
<tr><td style="padding:6px 0;color:#64748b;">Undokumentiert</td><td style="padding:6px 0;">${summary.undocumented || 0}</td></tr>
</table>
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
🔍 Phase A: Vor Einwilligung
</h3>
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt OHNE dass der Nutzer etwas geklickt hat?</p>
<table style="width:100%;border-collapse:collapse;">${violationRows(phases.before_consent?.violations || [])}</table>
${data.banner_detected ? `
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
🚫 Phase B: Nach Ablehnung
</h3>
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt NACHDEM der Nutzer "Nur notwendige" geklickt hat?</p>
<table style="width:100%;border-collapse:collapse;">${violationRows(phases.after_reject?.violations || [])}</table>
<h3 style="color:#1e293b;font-size:14px;margin:20px 0 8px;border-bottom:2px solid #e2e8f0;padding-bottom:6px;">
✅ Phase C: Nach Zustimmung
</h3>
<p style="color:#64748b;font-size:12px;margin:0 0 8px;">Was laedt NACHDEM der Nutzer "Alle akzeptieren" geklickt hat?</p>
<table style="width:100%;border-collapse:collapse;">${undocRows(phases.after_accept?.undocumented || [])}</table>
${(phases.after_accept?.undocumented?.length || 0) === 0 ? '<p style="color:#16a34a;font-size:13px;">✓ Alle Dienste dokumentiert</p>' : ''}
` : `
<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:12px;margin:12px 0;">
<strong style="color:#dc2626;">Kein Cookie-Banner erkannt.</strong>
Alle Tracking-Dienste laden ohne Einwilligung — Verstoss gegen §25 TDDDG.
</div>
`}
${(summary.critical || 0) > 0 ? `
<div style="background:#fef2f2;border-left:4px solid #dc2626;padding:12px 16px;margin-top:20px;">
<strong style="color:#991b1b;">⚠ KRITISCH:</strong> Tracking-Dienste laden trotz Ablehnung.
Dies ist ein schwerer Verstoss gegen §25 TDDDG und kann als Dark Pattern gewertet werden.
Sofortige Korrektur der Cookie-Banner-Konfiguration empfohlen.
</div>
` : ''}
</div>
<div style="background:#f8fafc;padding:12px 24px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px;">
<p style="color:#94a3b8;font-size:11px;margin:0;">
Automatisch erstellt vom BreakPilot Compliance Agent (Playwright + Chromium)
</p>
</div>
</div>
`
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const url = body.url
// Step 1: Run consent test
const response = await fetch(`${CONSENT_TESTER_URL}/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(180000),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: `Consent-Tester: ${response.status}`, detail: errorText },
{ status: response.status }
)
}
const data = await response.json()
// Step 2: Send email with phase-structured findings
try {
const total = (data.summary?.total_violations || 0)
const severity = (data.summary?.critical || 0) > 0 ? 'KRITISCH' : total > 0 ? 'FINDINGS' : 'OK'
await fetch(`${BACKEND_URL}/api/compliance/agent/notify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipient: body.recipient || 'dsb@breakpilot.local',
subject: `[COOKIE-TEST] [${severity}] ${url}${total} Verstoesse`,
body_html: buildEmailHtml({ ...data, url }),
role: total > 0 ? 'Datenschutzbeauftragter' : 'Kein Handlungsbedarf',
}),
signal: AbortSignal.timeout(10000),
})
} catch (emailErr) {
console.warn('Email send failed (non-blocking):', emailErr)
}
return NextResponse.json(data)
} catch (error) {
console.error('Consent test proxy error:', error)
return NextResponse.json(
{ error: 'Cookie-Test fehlgeschlagen oder Timeout' },
{ status: 503 }
)
}
}
@@ -0,0 +1,39 @@
/**
* Agent Doc-Check Proxy — Multi-URL document verification
* POST: start check, GET: poll status
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/doc-check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(30000),
})
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch (error) {
return NextResponse.json({ error: 'Pruefung konnte nicht gestartet werden' }, { status: 503 })
}
}
export async function GET(request: NextRequest) {
const checkId = request.nextUrl.searchParams.get('check_id')
if (!checkId) return NextResponse.json({ error: 'check_id required' }, { status: 400 })
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/doc-check/${checkId}`,
{ signal: AbortSignal.timeout(10000) },
)
const data = await response.json()
return NextResponse.json(data)
} catch {
return NextResponse.json({ error: 'Status-Abfrage fehlgeschlagen' }, { status: 503 })
}
}
@@ -0,0 +1,27 @@
/**
* Text Extraction Proxy — extract text from a URL via consent-tester
* POST: { url: string } -> { text, word_count, title, error }
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/extract-text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(120000),
})
const data = await response.json()
return NextResponse.json(data, { status: response.status })
} catch (error) {
return NextResponse.json(
{ text: '', word_count: 0, title: '', error: 'Text-Extraktion fehlgeschlagen' },
{ status: 503 },
)
}
}
@@ -0,0 +1,30 @@
/**
* Agent Notify API Proxy
* POST /api/sdk/v1/agent/notify → backend-compliance /api/compliance/agent/notify
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/notify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(15000),
})
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json({ error: errorText }, { status: response.status })
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Agent notify proxy error:', error)
return NextResponse.json({ error: 'Email-Versand fehlgeschlagen' }, { status: 503 })
}
}
@@ -1,6 +1,8 @@
/**
* Agent Scan API Proxy
* POST /api/sdk/v1/agent/scan → backend-compliance /api/compliance/agent/scan
* Agent Scan API Proxy — async scan with polling
*
* POST /api/sdk/v1/agent/scan → starts scan, returns scan_id
* GET /api/sdk/v1/agent/scan?scan_id=xxx → poll status/results
*/
import { NextRequest, NextResponse } from 'next/server'
@@ -11,11 +13,12 @@ export async function POST(request: NextRequest) {
try {
const body = await request.text()
// Start async scan — returns immediately with scan_id
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/scan`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(180000), // 3 min — multi-page scan + LLM
signal: AbortSignal.timeout(300000), // 5 min — multi-page scan + LLM calls
})
if (!response.ok) {
@@ -31,7 +34,36 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error('Agent scan proxy error:', error)
return NextResponse.json(
{ error: 'Scan fehlgeschlagen oder Timeout' },
{ error: 'Scan konnte nicht gestartet werden' },
{ status: 503 }
)
}
}
export async function GET(request: NextRequest) {
const scanId = request.nextUrl.searchParams.get('scan_id')
if (!scanId) {
return NextResponse.json({ error: 'scan_id parameter required' }, { status: 400 })
}
try {
const response = await fetch(
`${BACKEND_URL}/api/compliance/agent/scan/${scanId}`,
{ signal: AbortSignal.timeout(10000) }
)
if (!response.ok) {
return NextResponse.json(
{ error: `Backend: ${response.status}` },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
return NextResponse.json(
{ error: 'Status-Abfrage fehlgeschlagen' },
{ status: 503 }
)
}
@@ -0,0 +1,36 @@
/**
* PDF Export Proxy
* POST /api/sdk/v1/agent/scans/pdf → backend /api/compliance/agent/scans/pdf
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const response = await fetch(`${BACKEND_URL}/api/compliance/agent/scans/pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
return NextResponse.json({ error: 'PDF generation failed' }, { status: response.status })
}
const pdfBytes = await response.arrayBuffer()
return new NextResponse(pdfBytes, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': 'attachment; filename="compliance-report.pdf"',
},
})
} catch (error) {
console.error('PDF proxy error:', error)
return NextResponse.json({ error: 'PDF generation failed' }, { status: 503 })
}
}
@@ -0,0 +1,74 @@
/**
* Banner API Proxy — catch-all route for cookie banner endpoints.
*
* Maps: /api/sdk/v1/banner/<path> → backend-compliance:8002/api/compliance/banner/<path>
*
* Solves: Browser cannot call backend-compliance:8093 directly due to
* self-signed SSL certificates. This proxy runs server-side where
* certificate validation is not an issue.
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
async function proxyRequest(
request: NextRequest,
pathSegments: string[] | undefined,
method: string,
) {
const pathStr = pathSegments?.join('/') || ''
const qs = request.nextUrl.searchParams.toString()
const base = `${BACKEND_URL}/api/compliance/banner`
const url = pathStr
? `${base}/${pathStr}${qs ? `?${qs}` : ''}`
: `${base}${qs ? `?${qs}` : ''}`
try {
const headers: HeadersInit = {
'X-Tenant-ID': request.headers.get('x-tenant-id') || DEFAULT_TENANT,
}
const ct = request.headers.get('Content-Type')
if (ct) headers['Content-Type'] = ct
const opts: RequestInit = { method, headers, signal: AbortSignal.timeout(30000) }
if (method === 'POST' || method === 'PUT') {
const body = await request.text()
if (body) opts.body = body
}
const res = await fetch(url, opts)
const text = await res.text()
let data
try { data = JSON.parse(text) } catch { data = { raw: text } }
if (!res.ok) {
return NextResponse.json(
{ error: `Backend ${res.status}`, ...data },
{ status: res.status },
)
}
return NextResponse.json(data)
} catch (err: any) {
console.error('Banner proxy error:', err?.message)
return NextResponse.json(
{ error: 'Backend nicht erreichbar' },
{ status: 503 },
)
}
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return proxyRequest(req, (await params).path, 'GET')
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return proxyRequest(req, (await params).path, 'POST')
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return proxyRequest(req, (await params).path, 'PUT')
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return proxyRequest(req, (await params).path, 'DELETE')
}
@@ -0,0 +1,22 @@
/**
* DSMS Gateway Proxy — forwards verify/history requests to dsms-gateway.
*/
import { NextRequest, NextResponse } from 'next/server'
const DSMS_URL = process.env.DSMS_GATEWAY_URL || 'http://dsms-gateway:8082'
export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params
const target = `${DSMS_URL}/api/v1/${path.join('/')}`
try {
const resp = await fetch(target, {
headers: { Authorization: 'Bearer system-frontend' },
signal: AbortSignal.timeout(15000),
})
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch {
return NextResponse.json({ error: 'DSMS not available' }, { status: 503 })
}
}
@@ -23,12 +23,13 @@ function getTenantId(request: NextRequest): string {
*/
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const tenantId = getTenantId(request)
const response = await fetch(
`${BACKEND_URL}/api/compliance/einwilligungen/consents/${params.id}/history`,
`${BACKEND_URL}/api/compliance/einwilligungen/consents/${id}/history`,
{
method: 'GET',
headers: {
@@ -30,15 +30,15 @@ async function proxyRequest(
headers['Authorization'] = authHeader
}
// Default tenant/user for IACE (same pattern as training proxy)
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const DEFAULT_USER = '00000000-0000-0000-0000-000000000001'
const tenantHeader = request.headers.get('x-tenant-id')
if (tenantHeader) {
headers['X-Tenant-Id'] = tenantHeader
}
headers['X-Tenant-Id'] = tenantHeader || DEFAULT_TENANT
const userHeader = request.headers.get('x-user-id')
if (userHeader) {
headers['X-User-Id'] = userHeader
}
headers['X-User-Id'] = userHeader || DEFAULT_USER
const fetchOptions: RequestInit = {
method,
@@ -0,0 +1,229 @@
import { NextRequest, NextResponse } from 'next/server'
import { Pool } from 'pg'
// Disable SSL rejection for self-signed certs
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
const dbUrl = process.env.COMPLIANCE_DATABASE_URL ||
process.env.DATABASE_URL ||
'postgresql://breakpilot:breakpilot123@bp-core-postgres:5432/breakpilot_db'
const pool = new Pool({ connectionString: dbUrl })
/**
* MC API that returns data in the same format as the canonical controls
* endpoint. This allows the MC page to reuse ControlListView components.
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const endpoint = searchParams.get('endpoint') || 'controls'
switch (endpoint) {
case 'frameworks':
return NextResponse.json([])
case 'controls':
return handleControls(searchParams)
case 'controls-count':
return handleCount(searchParams)
case 'controls-meta':
return handleMeta(searchParams)
case 'control':
return handleDetail(searchParams)
default:
return NextResponse.json({ error: 'unknown' }, { status: 400 })
}
} catch (e) {
return NextResponse.json({ error: String(e) }, { status: 500 })
}
}
async function handleControls(params: URLSearchParams) {
const search = params.get('search') || ''
const limit = Math.min(parseInt(params.get('limit') || '50'), 200)
const offset = parseInt(params.get('offset') || '0')
const sort = params.get('sort') || 'control_id'
const order = params.get('order') === 'desc' ? 'DESC' : 'ASC'
let where = "WHERE 1=1"
const args: unknown[] = []
let idx = 1
if (search) {
where += ` AND mc.canonical_name ILIKE $${idx}`
args.push(`%${search}%`)
idx++
}
const severity = params.get('severity') || ''
if (severity) {
if (severity === 'high') { where += ` AND mc.total_controls > 100` }
else if (severity === 'medium') { where += ` AND mc.total_controls BETWEEN 20 AND 100` }
else if (severity === 'low') { where += ` AND mc.total_controls < 20` }
}
const domain = params.get('domain') || ''
if (domain) {
where += ` AND mc.canonical_name LIKE $${idx}`
args.push(`${domain}%`)
idx++
}
const sortCol = sort === 'control_id' ? 'mc.master_control_id' :
sort === 'created_at' ? 'mc.created_at' :
sort === 'source' ? 'mc.canonical_name' : 'mc.master_control_id'
args.push(limit, offset)
const res = await pool.query(`
SELECT mc.master_control_id as control_id,
mc.canonical_name as title,
'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective,
CASE WHEN mc.total_controls > 100 THEN 'high'
WHEN mc.total_controls > 20 THEN 'medium'
ELSE 'low' END as severity,
'master_control' as category,
mc.total_controls,
mc.phases_covered,
mc.id,
mc.created_at
FROM compliance.master_controls mc
${where}
ORDER BY ${sortCol} ${order}
LIMIT $${idx} OFFSET $${idx + 1}
`, args)
// Map to canonical control format
const controls = res.rows.map(r => ({
id: r.id,
control_id: r.control_id,
title: r.title,
objective: r.objective,
severity: r.severity,
category: r.category,
release_state: 'active',
source_citation: null,
verification_method: null,
evidence_type: null,
target_audience: [],
requirements: [],
test_procedure: [],
evidence: [],
open_anchors: [],
total_controls: r.total_controls,
phases_covered: r.phases_covered,
created_at: r.created_at,
scope: { platforms: [], components: [], data_classes: [] },
risk_score: null,
implementation_effort: null,
}))
return NextResponse.json(controls)
}
async function handleCount(params: URLSearchParams) {
const search = params.get('search') || ''
let where = "WHERE 1=1"
const args: unknown[] = []
if (search) {
where += ` AND mc.canonical_name ILIKE $1`
args.push(`%${search}%`)
}
const res = await pool.query(
`SELECT count(*) FROM compliance.master_controls mc ${where}`, args
)
return NextResponse.json({ total: parseInt(res.rows[0].count) })
}
async function handleMeta(params: URLSearchParams) {
const res = await pool.query(`
SELECT count(*) as total,
count(CASE WHEN total_controls > 100 THEN 1 END) as high_count,
count(CASE WHEN total_controls BETWEEN 20 AND 100 THEN 1 END) as medium_count,
count(CASE WHEN total_controls < 20 THEN 1 END) as low_count
FROM compliance.master_controls
`)
const r = res.rows[0]
// Get top L1 tokens as "domains"
const domainRes = await pool.query(`
SELECT split_part(canonical_name, '_', 1) as domain, count(*) as count
FROM compliance.master_controls
GROUP BY 1 ORDER BY 2 DESC LIMIT 30
`)
return NextResponse.json({
total: parseInt(r.total),
severity_counts: {
high: parseInt(r.high_count),
medium: parseInt(r.medium_count),
low: parseInt(r.low_count),
},
domains: domainRes.rows.map(d => ({ domain: d.domain, count: parseInt(d.count) })),
sources: [],
no_source_count: 0,
release_state_counts: { active: parseInt(r.total) },
verification_method_counts: {},
category_counts: {},
evidence_type_counts: {},
})
}
async function handleDetail(params: URLSearchParams) {
const id = params.get('id') || ''
const res = await pool.query(`
SELECT mc.id, mc.master_control_id as control_id, mc.canonical_name as title,
'Master Control mit ' || mc.total_controls || ' Atomic Controls' as objective,
mc.total_controls, mc.phases_covered, mc.phase_control_count, mc.created_at
FROM compliance.master_controls mc
WHERE mc.master_control_id = $1 OR mc.id::text = $1
`, [id])
if (res.rows.length === 0) {
return NextResponse.json({ error: 'not found' }, { status: 404 })
}
const mc = res.rows[0]
// Load members
const membersRes = await pool.query(`
SELECT cc.control_id, cc.title, cc.severity, mcm.phase, mcm.action
FROM compliance.master_control_members mcm
JOIN compliance.canonical_controls cc ON cc.id = mcm.control_uuid
WHERE mcm.master_control_uuid = $1
ORDER BY mcm.phase, cc.control_id
LIMIT 100
`, [mc.id])
return NextResponse.json({
id: mc.id,
control_id: mc.control_id,
title: mc.title,
objective: mc.objective,
severity: mc.total_controls > 100 ? 'high' : mc.total_controls > 20 ? 'medium' : 'low',
category: 'master_control',
release_state: 'active',
total_controls: mc.total_controls,
phases_covered: mc.phases_covered,
phase_control_count: mc.phase_control_count,
members: membersRes.rows,
requirements: membersRes.rows.map((m: { control_id: string; title: string; phase: string }) =>
`[${m.phase}] ${m.control_id}: ${m.title}`
),
test_procedure: [],
evidence: [],
open_anchors: [],
target_audience: [],
source_citation: null,
scope: { platforms: [], components: [], data_classes: [] },
risk_score: null,
implementation_effort: null,
created_at: mc.created_at,
})
}
@@ -39,14 +39,14 @@ async function proxy(request: NextRequest, params: { path?: string[] }, method:
}
}
export async function GET(request: NextRequest, { params }: { params: { path?: string[] } }) {
return proxy(request, params, 'GET')
export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return proxy(request, await params, 'GET')
}
export async function POST(request: NextRequest, { params }: { params: { path?: string[] } }) {
return proxy(request, params, 'POST')
export async function POST(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return proxy(request, await params, 'POST')
}
export async function DELETE(request: NextRequest, { params }: { params: { path?: string[] } }) {
return proxy(request, params, 'DELETE')
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
return proxy(request, await params, 'DELETE')
}
@@ -6,7 +6,7 @@ const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78
/**
* Proxy: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
*/
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
const { path } = await params
const subPath = path ? path.join('/') : ''
const search = request.nextUrl.search || ''
@@ -1,36 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
const DEFAULT_TENANT = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
/**
* Proxy: GET /api/sdk/v1/ucca/decision-tree → Go Backend GET /sdk/v1/ucca/decision-tree
* Returns the decision tree definition (questions, structure)
*/
export async function GET(request: NextRequest) {
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
try {
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/decision-tree`, {
headers: { 'X-Tenant-ID': tenantID },
})
if (!response.ok) {
const errorText = await response.text()
console.error('Decision tree GET error:', errorText)
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Decision tree proxy error:', error)
return NextResponse.json(
{ error: 'Failed to connect to AI compliance backend' },
{ status: 503 }
)
}
}
@@ -0,0 +1,53 @@
/**
* Vendor Assessment Status/Detail Proxy
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
try {
const resp = await fetch(
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}`,
{ signal: AbortSignal.timeout(10000) },
)
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (error) {
console.error('Assessment status proxy error:', error)
return NextResponse.json(
{ error: 'Backend nicht erreichbar' },
{ status: 503 },
)
}
}
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
try {
const resp = await fetch(
`${BACKEND_URL}/api/vendor-compliance/assessments/${id}/approve`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(10000),
},
)
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (error) {
console.error('Assessment approve proxy error:', error)
return NextResponse.json(
{ error: 'Backend nicht erreichbar' },
{ status: 503 },
)
}
}
@@ -0,0 +1,41 @@
/**
* Vendor Assessment API Proxy
* Proxies to backend-compliance (Python FastAPI)
*/
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002'
export async function POST(request: NextRequest) {
try {
const body = await request.text()
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
signal: AbortSignal.timeout(10000),
})
const data = await resp.json()
return NextResponse.json(data, { status: resp.status })
} catch (error) {
console.error('Vendor assessment proxy error:', error)
return NextResponse.json(
{ error: 'Backend nicht erreichbar' },
{ status: 503 },
)
}
}
export async function GET() {
try {
const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, {
signal: AbortSignal.timeout(10000),
})
const data = await resp.json()
return NextResponse.json(data)
} catch (error) {
console.error('Vendor assessment list proxy error:', error)
return NextResponse.json({ assessments: [] })
}
}
@@ -0,0 +1,92 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { COMPANY_PROFILE_PRESETS, type CompanyProfilePreset } from '@/lib/sdk/company-profile-presets'
import { DOC_LABELS, CATEGORY_COLORS } from './doc-labels'
export function PresetSection({ projectId }: { projectId?: string }) {
const [selectedPreset, setSelectedPreset] = useState<CompanyProfilePreset | null>(null)
// Group recommended docs by category
const groupedDocs = selectedPreset
? selectedPreset.recommendedDocs.reduce<Record<string, string[]>>((acc, docType) => {
const info = DOC_LABELS[docType]
if (!info) return acc
if (!acc[info.category]) acc[info.category] = []
acc[info.category].push(info.label)
return acc
}, {})
: null
return (
<div className="bg-gradient-to-br from-purple-50 to-white rounded-xl border border-purple-200 p-6 space-y-4">
<div>
<h2 className="text-lg font-bold text-gray-900">Schnellstart: Welcher Unternehmenstyp sind Sie?</h2>
<p className="text-sm text-gray-500 mt-1">
Waehlen Sie Ihre Branche wir zeigen Ihnen welche Dokumente Sie benoetigen.
</p>
</div>
{/* Preset Cards */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{COMPANY_PROFILE_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => setSelectedPreset(selectedPreset?.id === preset.id ? null : preset)}
className={`flex flex-col items-center gap-2 p-3 rounded-xl transition-all text-center ${
selectedPreset?.id === preset.id
? 'bg-purple-100 border-2 border-purple-500 shadow-md'
: 'bg-white border border-gray-200 hover:border-purple-300 hover:shadow-sm'
}`}
>
<span className="text-2xl">{preset.icon}</span>
<span className={`text-xs font-medium ${selectedPreset?.id === preset.id ? 'text-purple-700' : 'text-gray-900'}`}>
{preset.label}
</span>
<span className="text-[10px] text-gray-400 leading-tight">{preset.description}</span>
</button>
))}
</div>
{/* Document Preview — shown when a preset is selected */}
{selectedPreset && groupedDocs && (
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">
{selectedPreset.icon} {selectedPreset.label} Ihre Dokumente
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{selectedPreset.recommendedDocs.length} Dokumente werden fuer Sie vorbereitet
</p>
</div>
<Link
href={projectId
? `/sdk/company-profile?project=${projectId}&preset=${selectedPreset.id}`
: `/sdk/company-profile?preset=${selectedPreset.id}`}
className="px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors"
>
Jetzt starten
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{Object.entries(groupedDocs).map(([category, docs]) => (
<div key={category} className="space-y-1.5">
<span className={`inline-block px-2 py-0.5 rounded-full text-[10px] font-medium ${CATEGORY_COLORS[category] || 'bg-gray-100 text-gray-600'}`}>
{category}
</span>
{docs.map((doc) => (
<div key={doc} className="text-xs text-gray-700 pl-1">
{doc}
</div>
))}
</div>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,131 @@
/**
* Complete mapping of all document template types to display labels and categories.
* Used by PresetSection to show categorized document previews.
*/
export const DOC_LABELS: Record<string, { label: string; category: string }> = {
// ── Website ──────────────────────────────────────────────────────
privacy_policy: { label: 'Datenschutzerklaerung', category: 'Website' },
impressum: { label: 'Impressum', category: 'Website' },
cookie_policy: { label: 'Cookie-Richtlinie', category: 'Website' },
cookie_banner: { label: 'Cookie-Banner-Texte', category: 'Website' },
// ── Vertraege ────────────────────────────────────────────────────
agb: { label: 'AGB', category: 'Vertraege' },
dpa: { label: 'AVV (Auftragsverarbeitung)', category: 'Vertraege' },
nda: { label: 'Geheimhaltungsvereinbarung', category: 'Vertraege' },
sla: { label: 'Service Level Agreement', category: 'Vertraege' },
terms_of_use: { label: 'Nutzungsbedingungen', category: 'Vertraege' },
cloud_service_agreement: { label: 'Cloud-Vertrag', category: 'Vertraege' },
data_usage_clause: { label: 'Datennutzungsklausel', category: 'Vertraege' },
// ── Plattform ────────────────────────────────────────────────────
community_guidelines: { label: 'Community Guidelines', category: 'Plattform' },
acceptable_use: { label: 'Acceptable Use Policy', category: 'Plattform' },
media_content_policy: { label: 'Medien-Richtlinie', category: 'Plattform' },
copyright_policy: { label: 'Urheberrechtsrichtlinie', category: 'Plattform' },
// ── E-Commerce ───────────────────────────────────────────────────
widerruf: { label: 'Widerrufsbelehrung', category: 'E-Commerce' },
// ── HR / Personal ────────────────────────────────────────────────
employee_dsi: { label: 'Mitarbeiter-DSI', category: 'HR' },
applicant_dsi: { label: 'Bewerber-DSI', category: 'HR' },
whistleblower_policy: { label: 'Whistleblower-Richtlinie', category: 'HR' },
employee_security_policy: { label: 'Mitarbeiter-Sicherheitsrichtlinie', category: 'HR' },
security_awareness_policy: { label: 'Security-Awareness-Richtlinie', category: 'HR' },
remote_work_policy: { label: 'Remote-Work-Richtlinie', category: 'HR' },
offboarding_policy: { label: 'Offboarding-Richtlinie', category: 'HR' },
// ── Datenschutz (DSGVO) ──────────────────────────────────────────
tom_documentation: { label: 'TOM-Dokumentation', category: 'Datenschutz' },
vvt_register: { label: 'Verarbeitungsverzeichnis', category: 'Datenschutz' },
loeschkonzept: { label: 'Loeschkonzept', category: 'Datenschutz' },
dsfa: { label: 'Datenschutz-Folgenabschaetzung', category: 'Datenschutz' },
pflichtenregister: { label: 'Pflichtenregister', category: 'Datenschutz' },
data_protection_concept: { label: 'Datenschutzkonzept', category: 'Datenschutz' },
consent_texts: { label: 'Einwilligungstexte', category: 'Datenschutz' },
informationspflichten: { label: 'Informationspflichten', category: 'Datenschutz' },
verpflichtungserklaerung: { label: 'Verpflichtungserklaerung', category: 'Datenschutz' },
social_media_dsi: { label: 'Social-Media-DSI', category: 'Datenschutz' },
video_conference_dsi: { label: 'Videokonferenz-DSI', category: 'Datenschutz' },
// ── Daten-Policies ───────────────────────────────────────────────
data_protection_policy: { label: 'Datenschutzrichtlinie', category: 'Daten-Governance' },
data_classification_policy: { label: 'Datenklassifizierung', category: 'Daten-Governance' },
data_retention_policy: { label: 'Aufbewahrungsrichtlinie', category: 'Daten-Governance' },
data_transfer_policy: { label: 'Datentransfer-Richtlinie', category: 'Daten-Governance' },
privacy_incident_policy: { label: 'Datenschutzvorfall-Richtlinie', category: 'Daten-Governance' },
// ── Betroffenenrechte ────────────────────────────────────────────
dsr_process_art15: { label: 'Auskunftsrecht (Art. 15)', category: 'Betroffenenrechte' },
dsr_process_art16: { label: 'Berichtigungsrecht (Art. 16)', category: 'Betroffenenrechte' },
dsr_process_art17: { label: 'Loeschungsrecht (Art. 17)', category: 'Betroffenenrechte' },
dsr_process_art18: { label: 'Einschraenkungsrecht (Art. 18)', category: 'Betroffenenrechte' },
dsr_process_art19: { label: 'Mitteilungspflicht (Art. 19)', category: 'Betroffenenrechte' },
dsr_process_art20: { label: 'Datenportabilitaet (Art. 20)', category: 'Betroffenenrechte' },
dsr_process_art21: { label: 'Widerspruchsrecht (Art. 21)', category: 'Betroffenenrechte' },
// ── IT-Sicherheit (Konzepte) ─────────────────────────────────────
it_security_concept: { label: 'IT-Sicherheitskonzept', category: 'IT-Sicherheit' },
backup_recovery_concept: { label: 'Backup- & Recovery-Konzept', category: 'IT-Sicherheit' },
logging_concept: { label: 'Logging-Konzept', category: 'IT-Sicherheit' },
incident_response_plan: { label: 'Incident-Response-Plan', category: 'IT-Sicherheit' },
access_control_concept: { label: 'Zugriffskonzept', category: 'IT-Sicherheit' },
risk_management_concept: { label: 'Risikomanagement-Konzept', category: 'IT-Sicherheit' },
isms_manual: { label: 'ISMS-Handbuch', category: 'IT-Sicherheit' },
// ── IT-Sicherheit (Policies) ─────────────────────────────────────
information_security_policy: { label: 'Informationssicherheitsrichtlinie', category: 'IT-Policies' },
access_control_policy: { label: 'Zugriffskontrollrichtlinie', category: 'IT-Policies' },
password_policy: { label: 'Passwortrichtlinie', category: 'IT-Policies' },
encryption_policy: { label: 'Verschluesselungsrichtlinie', category: 'IT-Policies' },
logging_policy: { label: 'Protokollierungsrichtlinie', category: 'IT-Policies' },
backup_policy: { label: 'Datensicherungsrichtlinie', category: 'IT-Policies' },
incident_response_policy: { label: 'Incident-Response-Richtlinie', category: 'IT-Policies' },
change_management_policy: { label: 'Change-Management-Richtlinie', category: 'IT-Policies' },
patch_management_policy: { label: 'Patch-Management-Richtlinie', category: 'IT-Policies' },
asset_management_policy: { label: 'Asset-Management-Richtlinie', category: 'IT-Policies' },
cloud_security_policy: { label: 'Cloud-Security-Richtlinie', category: 'IT-Policies' },
devsecops_policy: { label: 'DevSecOps-Richtlinie', category: 'IT-Policies' },
secrets_management_policy: { label: 'Secrets-Management-Richtlinie', category: 'IT-Policies' },
vulnerability_management_policy: { label: 'Schwachstellenmanagement', category: 'IT-Policies' },
// ── Lieferanten / Drittanbieter ──────────────────────────────────
vendor_risk_management_policy: { label: 'Lieferanten-Risikomanagement', category: 'Lieferanten' },
third_party_security_policy: { label: 'Drittanbieter-Sicherheit', category: 'Lieferanten' },
supplier_security_policy: { label: 'Lieferanten-Anforderungen', category: 'Lieferanten' },
transfer_impact_assessment: { label: 'Transfer Impact Assessment', category: 'Lieferanten' },
scc_companion: { label: 'SCC-Begleitdokument', category: 'Lieferanten' },
// ── BCM / Notfall ────────────────────────────────────────────────
business_continuity_policy: { label: 'Business-Continuity', category: 'BCM' },
disaster_recovery_policy: { label: 'Disaster-Recovery', category: 'BCM' },
crisis_management_policy: { label: 'Krisenmanagement', category: 'BCM' },
// ── KI / Cyber ───────────────────────────────────────────────────
ai_usage_policy: { label: 'KI-Nutzungsrichtlinie', category: 'KI & Cyber' },
cybersecurity_policy: { label: 'Cybersecurity-Richtlinie (CRA)', category: 'KI & Cyber' },
byod_policy: { label: 'BYOD-Richtlinie', category: 'KI & Cyber' },
// ── SOP ──────────────────────────────────────────────────────────
standard_operating_procedure: { label: 'Standard Operating Procedure', category: 'Prozesse' },
}
export const CATEGORY_COLORS: Record<string, string> = {
Website: 'bg-blue-50 text-blue-700',
Vertraege: 'bg-purple-50 text-purple-700',
Plattform: 'bg-indigo-50 text-indigo-700',
'E-Commerce': 'bg-green-50 text-green-700',
HR: 'bg-amber-50 text-amber-700',
Datenschutz: 'bg-red-50 text-red-700',
'Daten-Governance': 'bg-rose-50 text-rose-700',
Betroffenenrechte: 'bg-fuchsia-50 text-fuchsia-700',
'IT-Sicherheit': 'bg-gray-100 text-gray-700',
'IT-Policies': 'bg-slate-100 text-slate-700',
Lieferanten: 'bg-orange-50 text-orange-700',
BCM: 'bg-yellow-50 text-yellow-700',
'KI & Cyber': 'bg-cyan-50 text-cyan-700',
Marketing: 'bg-pink-50 text-pink-700',
Prozesse: 'bg-teal-50 text-teal-700',
}
@@ -0,0 +1,73 @@
'use client'
import React from 'react'
interface AuthCheck {
found: boolean
text: string
legal_ref: string
}
interface AuthData {
url: string
authenticated: boolean
login_error: string
checks: Record<string, AuthCheck>
findings_count: number
}
const CHECK_LABELS: Record<string, { label: string; icon: string }> = {
cancel_subscription: { label: 'Kuendigungsbutton (2 Klicks)', icon: '🚫' },
delete_account: { label: 'Konto loeschen', icon: '🗑️' },
export_data: { label: 'Daten exportieren', icon: '📥' },
consent_settings: { label: 'Einwilligungen widerrufen', icon: '⚙️' },
profile_visible: { label: 'Profildaten einsehen', icon: '👤' },
}
export function AuthTestResult({ data }: { data: AuthData }) {
if (!data.authenticated) {
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm font-medium text-red-800">Login fehlgeschlagen</p>
<p className="text-xs text-red-600 mt-1">{data.login_error || 'Credentials oder Formular nicht erkannt'}</p>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm font-medium text-gray-900">Erfolgreich eingeloggt</span>
<span className={`ml-auto text-xs px-2 py-1 rounded font-medium ${data.findings_count > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
{data.findings_count} fehlende Funktionen
</span>
</div>
<div className="space-y-2">
{Object.entries(data.checks).map(([key, check]) => {
const info = CHECK_LABELS[key] || { label: key, icon: '❓' }
return (
<div key={key} className={`flex items-center gap-3 p-3 rounded-lg border ${check.found ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
<span className="text-lg">{info.icon}</span>
<div className="flex-1">
<p className={`text-sm font-medium ${check.found ? 'text-green-800' : 'text-red-800'}`}>
{check.found ? '✓' : '✗'} {info.label}
</p>
{check.text && <p className="text-xs text-gray-500 mt-0.5">{check.text}</p>}
</div>
<span className="text-[10px] text-gray-400">{check.legal_ref}</span>
</div>
)
})}
</div>
{data.findings_count > 0 && (
<div className="bg-red-50 border-l-4 border-red-500 p-3 text-xs text-red-700">
<strong>{data.findings_count} Pflichtfunktion(en) fehlen.</strong> Der Nutzer kann seine Rechte
nach DSGVO nicht vollstaendig ausueben.
</div>
)}
</div>
)
}
@@ -0,0 +1,374 @@
'use client'
import React, { useState } from 'react'
import { ChecklistView } from './ChecklistView'
interface CheckItem {
id: string
label: string
passed: boolean
severity: string
matched_text: string
level?: number
parent?: string | null
skipped?: boolean
hint?: string
}
interface BannerResult {
banner_detected: boolean
banner_provider: string
banner_checks?: {
violations: { code: string; text: string; severity: string }[]
has_impressum_link?: boolean
has_dse_link?: boolean
}
structured_checks?: CheckItem[]
completeness_pct?: number
correctness_pct?: number
phases?: {
before_consent: { cookies: string[]; scripts: string[]; tracking_services: string[]; violations: any[] }
after_reject: { cookies: string[]; scripts: string[]; new_tracking: string[]; violations: any[] }
after_accept: { cookies: string[]; scripts: string[]; new_tracking: string[]; undocumented: string[] }
}
email_status?: string
}
const CATEGORIES = [
{ id: 'all', label: 'Alle Kategorien' },
{ id: 'necessary', label: 'Notwendig' },
{ id: 'statistics', label: 'Statistik' },
{ id: 'marketing', label: 'Marketing' },
{ id: 'functional', label: 'Funktional' },
{ id: 'preferences', label: 'Praeferenzen' },
]
export function BannerCheckTab() {
const [url, setUrl] = useState(() =>
typeof window !== 'undefined' ? localStorage.getItem('banner-check-url') || '' : ''
)
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState('')
const [error, setError] = useState<string | null>(null)
const [result, setResult] = useState<BannerResult | null>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem('banner-check-result'); return s ? JSON.parse(s) : null } catch { return null }
})
const [categories, setCategories] = useState<string[]>(['all'])
const [useAgent, setUseAgent] = useState(false)
const [mcResults, setMcResults] = useState<any>(null)
const [history, setHistory] = useState<{ url: string; date: string; provider: string; violations: number; pct: number; resultKey: string }[]>(() => {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem('banner-check-history') || '[]') } catch { return [] }
})
// Persist URL
React.useEffect(() => { localStorage.setItem('banner-check-url', url) }, [url])
const toggleCategory = (id: string) => {
if (id === 'all') {
setCategories(['all'])
return
}
setCategories(prev => {
const without = prev.filter(c => c !== 'all' && c !== id)
const next = prev.includes(id) ? without : [...without, id]
return next.length === 0 ? ['all'] : next
})
}
const handleScan = async (e: React.FormEvent) => {
e.preventDefault()
if (!url.trim()) return
setLoading(true)
setError(null)
setResult(null)
setProgress('Cookie-Banner wird analysiert...')
const selectedCategories = categories.includes('all') ? [] : categories
try {
const res = await fetch('/api/sdk/v1/agent/banner-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.trim(), categories: selectedCategories }),
})
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
const data = await res.json()
setResult(data)
localStorage.setItem('banner-check-result', JSON.stringify(data))
// If agent mode: also run cookie doc-check with 381 MCs
if (useAgent) {
setProgress('KI-Agent prueft Cookie-Richtlinie (381 MCs)...')
try {
const mcRes = await fetch('/api/sdk/v1/agent/doc-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entries: [{ doc_type: 'cookie', label: 'Cookie-Richtlinie', url: url.trim() }],
recipient: 'dsb@breakpilot.local',
use_agent: true,
}),
})
if (mcRes.ok) {
const { check_id } = await mcRes.json()
if (check_id) {
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000))
const poll = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
if (!poll.ok) continue
const pd = await poll.json()
if (pd.progress) setProgress(`KI-Agent: ${pd.progress}`)
if (pd.status === 'completed' && pd.result) { setMcResults(pd.result); break }
if (pd.status === 'failed') break
}
}
}
} catch { /* agent check is optional */ }
}
// Add to history with persistent result
const violations = data.structured_checks?.filter((c: CheckItem) => !c.passed && !c.skipped).length || 0
const resultKey = `banner-check-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(data)) } catch { /* quota */ }
const entry = {
url: url.trim(),
date: new Date().toISOString(),
provider: data.banner_provider || 'Unbekannt',
violations,
pct: data.completeness_pct ?? 0,
resultKey,
}
const updated = [entry, ...history].slice(0, 30)
setHistory(updated)
localStorage.setItem('banner-check-history', JSON.stringify(updated))
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
setProgress('')
}
}
const loadFromHistory = (entry: { url: string; resultKey?: string }) => {
setUrl(entry.url)
if (entry.resultKey) {
try {
const saved = localStorage.getItem(entry.resultKey)
if (saved) { setResult(JSON.parse(saved)); return }
} catch {}
}
// Fallback: load last result
try {
const last = localStorage.getItem('banner-check-result')
if (last) setResult(JSON.parse(last))
} catch {}
}
const structuredChecks = result?.structured_checks || []
const hasStructured = structuredChecks.length > 0
const compPct = result?.completeness_pct ?? 0
const corrPct = result?.correctness_pct ?? 0
const checklistResults = hasStructured ? [{
label: `Cookie-Banner: ${result?.banner_provider || 'Unbekannt'}`,
url: url,
doc_type: 'banner',
word_count: 0,
completeness_pct: compPct,
correctness_pct: corrPct,
checks: structuredChecks,
findings_count: structuredChecks.filter(c => !c.passed && !c.skipped).length,
error: '',
}] : []
return (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-blue-900">Cookie-Banner Compliance Check</h3>
<p className="text-xs text-blue-700 mt-1">
Playwright-basierter 3-Phasen-Test: Vor Interaktion, nach Ablehnen, nach Akzeptieren.
Prueft Dark Patterns, Pre-Consent-Cookies, Farbkontrast, Klick-Paritaet und 36 weitere Kriterien.
</p>
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={() => setUseAgent(!useAgent)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
useAgent ? 'bg-emerald-100 border-emerald-300 text-emerald-800' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
{useAgent ? 'KI-Agent aktiv (381 Cookie-MCs)' : 'KI-Agent aus'}
</button>
</div>
<form onSubmit={handleScan} className="space-y-3">
<div className="flex gap-3">
<input
type="url" value={url} onChange={e => setUrl(e.target.value)}
placeholder="https://www.example.com/"
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
disabled={loading} required
/>
<button type="submit" disabled={loading || !url.trim()}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
{loading ? (
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>Pruefe...</>
) : 'Banner pruefen'}
</button>
</div>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map(cat => (
<label key={cat.id}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium cursor-pointer border transition-colors ${
categories.includes(cat.id)
? 'bg-purple-100 border-purple-300 text-purple-800'
: 'bg-gray-50 border-gray-200 text-gray-600 hover:bg-gray-100'
}`}
>
<input type="checkbox" checked={categories.includes(cat.id)}
onChange={() => toggleCategory(cat.id)} className="sr-only" />
<span className={`w-3 h-3 rounded-sm border flex items-center justify-center ${
categories.includes(cat.id) ? 'bg-purple-600 border-purple-600' : 'border-gray-400'
}`}>
{categories.includes(cat.id) && (
<svg className="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 12 12">
<path d="M10 3L4.5 8.5 2 6" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
{cat.label}
</label>
))}
</div>
</form>
{progress && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3">
<svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{progress}
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>
)}
{result && (
<div className="space-y-4">
{result.phases && (
<div className="bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200">
<div className="flex items-center gap-3">
<span className="text-2xl">{result.banner_detected ? '🛡️' : '⚠️'}</span>
<div>
<h3 className="text-sm font-semibold text-gray-900">
{result.banner_detected
? `Banner erkannt: ${result.banner_provider || 'Unbekannter Anbieter'}`
: 'Kein Cookie-Banner erkannt'}
</h3>
<p className="text-xs text-gray-500 mt-0.5">3-Phasen-Analyse: Cookies und Scripts vor/nach Interaktion</p>
</div>
</div>
</div>
<div className="px-6 py-3 grid grid-cols-3 gap-4">
<PhaseBox label="Vor Consent" icon="🔒"
cookies={result.phases.before_consent.cookies?.length ?? 0}
scripts={result.phases.before_consent.scripts?.length ?? 0}
violations={result.phases.before_consent.violations?.length ?? 0} />
<PhaseBox label="Nach Ablehnen" icon="🚫"
cookies={result.phases.after_reject.cookies?.length ?? 0}
scripts={result.phases.after_reject.scripts?.length ?? 0}
violations={result.phases.after_reject.violations?.length ?? 0} />
<PhaseBox label="Nach Akzeptieren" icon="&#x2705;"
cookies={result.phases.after_accept.cookies?.length ?? 0}
scripts={result.phases.after_accept.scripts?.length ?? 0}
violations={0} />
</div>
</div>
)}
{hasStructured && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<ChecklistView results={checklistResults} />
</div>
)}
{result.email_status && (
<div className="text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${result.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {result.email_status === 'sent' ? 'Gesendet' : result.email_status}
</div>
)}
{/* MC Agent Results (Cookie-Richtlinie) */}
{mcResults?.results && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h4 className="text-sm font-semibold text-gray-800 mb-3">KI-Agent: Cookie-Richtlinie (381 MCs)</h4>
<ChecklistView results={mcResults.results} />
</div>
)}
{!result.banner_detected && !hasStructured && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<p className="text-sm text-gray-500">
Kein Cookie-Banner auf dieser Seite gefunden. Falls Cookies gesetzt werden, ist ein Banner nach §25 TDDDG Pflicht.
</p>
</div>
)}
</div>
)}
{/* History */}
{history.length > 0 && (
<div className="border border-gray-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Banner-Checks</h4>
<div className="space-y-1">
{history.map((h, i) => (
<button key={i} onClick={() => loadFromHistory(h)}
className="w-full flex items-center justify-between p-2.5 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
<div className="text-xs text-gray-500">
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
{' · '}{h.provider}
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
<span className={`text-xs font-medium ${h.violations > 0 ? 'text-red-600' : 'text-green-600'}`}>
{h.violations} Findings
</span>
<span className={`text-xs font-medium ${h.pct === 100 ? 'text-green-700' : h.pct >= 50 ? 'text-yellow-700' : 'text-red-700'}`}>
{h.pct}%
</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
)
}
function PhaseBox({ label, icon, cookies, scripts, violations }: {
label: string; icon: string; cookies: number; scripts: number; violations: number
}) {
return (
<div className="text-center">
<div className="text-lg">{icon}</div>
<div className="text-xs font-medium text-gray-700">{label}</div>
<div className="text-xs text-gray-500 mt-1">{cookies} Cookies, {scripts} Scripts</div>
{violations > 0 && <div className="text-xs text-red-600 font-medium">{violations} Verstoesse</div>}
</div>
)
}
@@ -0,0 +1,279 @@
'use client'
import React, { useState } from 'react'
interface CheckItem {
id: string
label: string
passed: boolean
severity: string
matched_text: string
level?: number
parent?: string | null
skipped?: boolean
hint?: string
}
interface DocResult {
label: string
url: string
doc_type: string
word_count: number
completeness_pct: number
correctness_pct?: number
checks: CheckItem[]
findings_count: number
error: string
scenario?: string // regenerate | fix | import | skip
}
const SCENARIO_LABELS: Record<string, { label: string; color: string; bg: string }> = {
regenerate: { label: 'Neugenerierung', color: 'text-red-700', bg: 'bg-red-100' },
fix: { label: 'Korrekturen', color: 'text-amber-700', bg: 'bg-amber-100' },
import: { label: 'Konform', color: 'text-green-700', bg: 'bg-green-100' },
}
const DOC_TYPE_LABELS: Record<string, string> = {
dse: 'DSI', agb: 'AGB', impressum: 'Impressum',
cookie: 'Cookie', widerruf: 'Widerruf', other: 'Sonstiges',
social_media: 'Social Media', dsfa: 'DSFA', joint_controller: 'Art. 26',
eu_institution: 'EU-Inst.', banner: 'Banner',
}
interface GroupedCheck {
check: CheckItem
children: CheckItem[]
}
function groupChecks(checks: CheckItem[]): GroupedCheck[] {
const l1 = checks.filter(c => (c.level ?? 1) === 1)
return l1.map(c => ({
check: c,
children: checks.filter(ch => ch.parent === c.id && (ch.level ?? 1) === 2),
}))
}
function CheckIcon({ passed, skipped, isInfo }: { passed: boolean; skipped?: boolean; isInfo?: boolean }) {
if (skipped) {
return (
<svg className="w-4 h-4 text-gray-300 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
)
}
if (passed) {
return (
<svg className="w-4 h-4 text-green-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)
}
if (isInfo) {
return (
<svg className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
return (
<svg className="w-4 h-4 text-red-500 mt-0.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)
}
function L2Summary({ children }: { children: CheckItem[] }) {
const active = children.filter(c => !c.skipped)
if (active.length === 0) return null
const passed = active.filter(c => c.passed).length
return (
<span className="text-xs text-gray-400 ml-1">
({passed}/{active.length})
</span>
)
}
export function ChecklistView({ results }: { results: DocResult[] }) {
const [expanded, setExpanded] = useState<number | null>(null)
if (!results || results.length === 0) return null
const scenarioCounts = {
regenerate: results.filter(r => r.scenario === 'regenerate').length,
fix: results.filter(r => r.scenario === 'fix').length,
import: results.filter(r => r.scenario === 'import').length,
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-sm font-semibold text-gray-800">
Dokumenten-Pruefung ({results.length} Dokumente)
</h3>
<div className="flex gap-2 text-[10px]">
{scenarioCounts.import > 0 && <span className="bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{scenarioCounts.import} konform</span>}
{scenarioCounts.fix > 0 && <span className="bg-amber-100 text-amber-700 px-2 py-0.5 rounded-full">{scenarioCounts.fix} Korrekturen</span>}
{scenarioCounts.regenerate > 0 && <span className="bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{scenarioCounts.regenerate} Neugenerierung</span>}
</div>
</div>
<div className="space-y-2">
{results.map((r, i) => {
const isExp = expanded === i
const pct = r.completeness_pct
const cpct = r.correctness_pct ?? 0
const barColor = pct === 100 ? 'bg-green-500' : pct >= 80 ? 'bg-green-400' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
const cBarColor = cpct >= 80 ? 'bg-blue-400' : cpct >= 50 ? 'bg-blue-300' : 'bg-blue-200'
const typeLabel = DOC_TYPE_LABELS[r.doc_type] || r.doc_type
const grouped = groupChecks(r.checks)
const l1Checks = r.checks.filter(c => (c.level ?? 1) === 1)
const l1Scoreable = l1Checks.filter(c => c.severity !== 'INFO')
const l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
const l1Passed = l1Scoreable.filter(c => c.passed).length
const l2Passed = l2Active.filter(c => c.passed).length
return (
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setExpanded(isExp ? null : i)}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-50 text-left"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<svg className={`w-4 h-4 text-gray-400 transition-transform shrink-0 ${isExp ? 'rotate-90' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600 font-medium shrink-0">
{typeLabel}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate flex items-center gap-2">
{r.label}
{r.scenario && SCENARIO_LABELS[r.scenario] && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${SCENARIO_LABELS[r.scenario].bg} ${SCENARIO_LABELS[r.scenario].color}`}>
{SCENARIO_LABELS[r.scenario].label}
</span>
)}
</div>
<div className="text-xs text-gray-500 truncate">
{l1Checks.length > 0
? `${l1Passed}/${l1Scoreable.length} Pflichtangaben`
+ (l2Active.length > 0 ? `, ${l2Passed}/${l2Active.length} Detailpruefungen` : '')
: r.url}
</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
{r.error ? (
<span className="text-xs text-red-600 font-medium">Fehler</span>
) : (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2" title={`Pflichtangaben: ${l1Passed}/${l1Scoreable.length}`}>
<span className="text-[10px] text-gray-400 w-7">Pflicht</span>
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs font-medium w-10 text-right ${
pct === 100 ? 'text-green-700' : pct >= 50 ? 'text-yellow-700' : 'text-red-700'
}`}>{pct}%</span>
</div>
{l2Active.length > 0 && (
<div className="flex items-center gap-2" title={`Detailpruefung: ${l2Passed}/${l2Active.length}`}>
<span className="text-[10px] text-gray-400 w-7">Detail</span>
<div className="w-14 h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${cBarColor}`} style={{ width: `${cpct}%` }} />
</div>
<span className="text-xs font-medium w-10 text-right text-blue-600">{cpct}%</span>
</div>
)}
</div>
)}
</div>
</button>
{isExp && (
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
{r.error ? (
<p className="text-sm text-red-600">{r.error}</p>
) : (
<div className="space-y-1">
{grouped.map((g) => {
const l1Info = g.check.severity === 'INFO' && !g.check.passed
return (
<div key={g.check.id}>
{/* L1 check */}
<div className="flex items-start gap-2">
<CheckIcon passed={g.check.passed} isInfo={l1Info} />
<div className="flex-1">
<div className={`text-sm ${
g.check.passed ? 'text-gray-700'
: l1Info ? 'text-gray-500' : 'text-red-700 font-medium'
}`}>
{g.check.label}
{g.children.length > 0 && <L2Summary>{g.children}</L2Summary>}
</div>
{g.check.passed && g.check.matched_text && g.children.length === 0 && (
<div className="text-xs text-gray-400 mt-0.5 font-mono truncate">
&quot;...{g.check.matched_text}...&quot;
</div>
)}
{!g.check.passed && g.check.hint && (
<div className={`text-xs mt-0.5 ${l1Info ? 'text-gray-400' : 'text-red-600/80'}`}>
{g.check.hint}
</div>
)}
</div>
</div>
{/* L2 children — always visible */}
{g.children.length > 0 && (
<div className="ml-6 mt-0.5 mb-1 space-y-0.5 border-l-2 border-gray-200 pl-3">
{g.children.map((ch) => {
const chInfo = ch.severity === 'INFO' && !ch.passed && !ch.skipped
return (
<div key={ch.id} className="flex items-start gap-2">
<CheckIcon passed={ch.passed} skipped={ch.skipped} isInfo={chInfo} />
<div className="flex-1">
<div className={`text-xs ${
ch.skipped ? 'text-gray-400 italic'
: ch.passed ? 'text-gray-600'
: chInfo ? 'text-gray-400' : 'text-red-600 font-medium'
}`}>
{ch.label}
{ch.skipped && ' (uebersprungen)'}
</div>
{ch.passed && ch.matched_text && (
<div className="text-xs text-gray-400 mt-0.5 font-mono truncate">
&quot;...{ch.matched_text}...&quot;
</div>
)}
{!ch.passed && !ch.skipped && ch.hint && (
<div className={`text-xs mt-0.5 ${chInfo ? 'text-gray-400' : 'text-red-500/80'}`}>
{ch.hint}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
{r.word_count > 0 && (
<div className="text-xs text-gray-400 mt-2 pt-2 border-t border-gray-200">
{r.word_count} Woerter analysiert
</div>
)}
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,96 @@
'use client'
import React from 'react'
interface SiteResult {
url: string
domain: string
risk_level: string
risk_score: number
findings_count: number
services_count: number
has_impressum: boolean
has_datenschutz: boolean
has_cookie_banner: boolean
has_google_fonts: boolean
scan_status: string
}
const RISK_COLOR: Record<string, string> = {
MINIMAL: 'text-green-700 bg-green-50',
LOW: 'text-yellow-700 bg-yellow-50',
LIMITED: 'text-orange-700 bg-orange-50',
HIGH: 'text-red-700 bg-red-50',
UNACCEPTABLE: 'text-red-900 bg-red-100',
}
export function CompareResult({ sites }: { sites: SiteResult[] }) {
if (!sites.length) return null
const checks = [
{ key: 'has_datenschutz', label: 'Datenschutzerklaerung' },
{ key: 'has_impressum', label: 'Impressum' },
{ key: 'has_cookie_banner', label: 'Cookie-Banner' },
{ key: 'has_google_fonts', label: 'Google Fonts (lokal?)' },
]
return (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-gray-50">
<th className="text-left px-3 py-2 text-xs font-medium text-gray-500 w-44">Pruefung</th>
{sites.map((s, i) => (
<th key={i} className="text-center px-3 py-2 text-xs font-medium text-gray-700">
{s.domain}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
<tr>
<td className="px-3 py-2 text-gray-600">Risiko-Score</td>
{sites.map((s, i) => (
<td key={i} className="px-3 py-2 text-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${RISK_COLOR[s.risk_level] || 'text-gray-600 bg-gray-50'}`}>
{s.risk_level || '?'} ({s.risk_score}/100)
</span>
</td>
))}
</tr>
<tr>
<td className="px-3 py-2 text-gray-600">Findings</td>
{sites.map((s, i) => (
<td key={i} className={`px-3 py-2 text-center font-medium ${s.findings_count > 0 ? 'text-red-700' : 'text-green-700'}`}>
{s.findings_count}
</td>
))}
</tr>
<tr>
<td className="px-3 py-2 text-gray-600">Dienste erkannt</td>
{sites.map((s, i) => (
<td key={i} className="px-3 py-2 text-center text-gray-700">{s.services_count}</td>
))}
</tr>
{checks.map(check => (
<tr key={check.key}>
<td className="px-3 py-2 text-gray-600">{check.label}</td>
{sites.map((s, i) => {
const val = (s as any)[check.key]
const isInverted = check.key === 'has_google_fonts'
const good = isInverted ? !val : val
return (
<td key={i} className={`px-3 py-2 text-center font-medium ${good ? 'text-green-600' : 'text-red-600'}`}>
{good ? '✓' : '✗'}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,482 @@
'use client'
import React, { useState, useCallback } from 'react'
import { ChecklistView } from './ChecklistView'
import { DocumentRow } from './DocumentRow'
const DOCUMENT_TYPES = [
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
{ id: 'impressum', label: 'Impressum', required: true },
{ id: 'social_media', label: 'Social Media DSE', required: false },
{ id: 'cookie', label: 'Cookie-Richtlinie', required: false },
{ id: 'agb', label: 'AGB', required: false },
{ id: 'nutzungsbedingungen', label: 'Nutzungsbedingungen', required: false },
{ id: 'widerruf', label: 'Widerrufsbelehrung', required: false },
{ id: 'dsb', label: 'DSB-Kontakt', required: false },
] as const
type DocTypeId = typeof DOCUMENT_TYPES[number]['id']
interface DocState {
url: string
text: string
loading: boolean
error: string | null
}
type DocsState = Record<DocTypeId, DocState>
const STORAGE_KEY_STATE = 'compliance-check-state'
const STORAGE_KEY_RESULTS = 'compliance-check-results'
const STORAGE_KEY_HISTORY = 'compliance-check-history'
const STORAGE_KEY_CHECK_ID = 'compliance-check-active-id'
function emptyDocState(): DocState {
return { url: '', text: '', loading: false, error: null }
}
function initState(): DocsState {
if (typeof window === 'undefined') {
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
}
try {
const saved = localStorage.getItem(STORAGE_KEY_STATE)
if (saved) {
const parsed = JSON.parse(saved) as Record<string, { url?: string; text?: string }>
return Object.fromEntries(
DOCUMENT_TYPES.map(d => [d.id, {
url: parsed[d.id]?.url || '',
text: parsed[d.id]?.text || '',
loading: false,
error: null,
}])
) as DocsState
}
} catch { /* ignore */ }
return Object.fromEntries(DOCUMENT_TYPES.map(d => [d.id, emptyDocState()])) as DocsState
}
function countWords(text: string): number {
if (!text.trim()) return 0
return text.trim().split(/\s+/).length
}
interface HistoryEntry {
date: string
docCount: number
findings: number
resultKey: string
}
export function ComplianceCheckTab() {
const [docs, setDocs] = useState<DocsState>(initState)
const [useAgent, setUseAgent] = useState(false)
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState('')
const [results, setResults] = useState<any>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem(STORAGE_KEY_RESULTS); return s ? JSON.parse(s) : null } catch { return null }
})
const [error, setError] = useState<string | null>(null)
const [activeCheckId, setActiveCheckId] = useState<string>(() =>
typeof window !== 'undefined' ? localStorage.getItem(STORAGE_KEY_CHECK_ID) || '' : ''
)
const [history, setHistory] = useState<HistoryEntry[]>(() => {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem(STORAGE_KEY_HISTORY) || '[]') } catch { return [] }
})
// Persist URLs and texts (not loading/error state)
React.useEffect(() => {
const toSave: Record<string, { url: string; text: string }> = {}
for (const [key, val] of Object.entries(docs)) {
toSave[key] = { url: val.url, text: val.text }
}
try { localStorage.setItem(STORAGE_KEY_STATE, JSON.stringify(toSave)) } catch { /* quota */ }
}, [docs])
// Resume polling if check was in progress when navigating away
React.useEffect(() => {
if (!activeCheckId || results) return
let cancelled = false
setLoading(true)
setProgress('Pruefung laeuft noch...')
const poll = async () => {
while (!cancelled) {
await new Promise(r => setTimeout(r, 3000))
try {
const res = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${activeCheckId}`)
if (!res.ok) continue
const data = await res.json()
if (data.progress) setProgress(data.progress)
if (data.status === 'completed' && data.result) {
setResults(data.result); setProgress(''); setLoading(false)
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(data.result))
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
return
}
if (data.status === 'failed' || data.status === 'not_found') {
if (data.status === 'failed') setError(data.error || 'Pruefung fehlgeschlagen')
setProgress(''); setLoading(false)
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
return
}
} catch { /* retry */ }
}
}
poll()
return () => { cancelled = true }
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const updateDoc = useCallback((docType: DocTypeId, patch: Partial<DocState>) => {
setDocs(prev => ({ ...prev, [docType]: { ...prev[docType], ...patch } }))
}, [])
const handleFetchText = useCallback(async (docType: DocTypeId) => {
const url = docs[docType].url.trim()
if (!url) return
updateDoc(docType, { loading: true, error: null })
try {
const res = await fetch('/api/sdk/v1/agent/extract-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
})
if (!res.ok) {
const msg = res.status === 404
? 'Seite nicht erreichbar'
: `Fehler beim Laden (${res.status})`
throw new Error(msg)
}
const data = await res.json()
updateDoc(docType, { text: data.text || '', loading: false })
} catch (e) {
updateDoc(docType, {
loading: false,
error: e instanceof Error ? e.message : 'Text konnte nicht geladen werden',
})
}
}, [docs, updateDoc])
const handleFileUpload = useCallback(async (docType: DocTypeId, file: File) => {
// For now, read as text. PDF/DOCX parsing can be added server-side later.
const reader = new FileReader()
reader.onload = () => {
updateDoc(docType, { text: reader.result as string })
}
reader.readAsText(file)
}, [updateDoc])
const filledCount = Object.values(docs).filter(d => d.url.trim() || d.text.trim()).length
const handleSubmit = async () => {
if (filledCount === 0) return
setLoading(true)
setError(null)
setResults(null)
setProgress('Compliance-Check wird gestartet...')
try {
const entries = DOCUMENT_TYPES
.filter(dt => docs[dt.id].url.trim() || docs[dt.id].text.trim())
.map(dt => ({
doc_type: dt.id,
label: dt.label,
url: docs[dt.id].url.trim(),
text: docs[dt.id].text.trim() || undefined,
}))
const startRes = await fetch('/api/sdk/v1/agent/compliance-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documents: entries,
use_agent: useAgent,
}),
})
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
const { check_id } = await startRes.json()
if (!check_id) throw new Error('Keine Check-ID erhalten')
setActiveCheckId(check_id)
localStorage.setItem(STORAGE_KEY_CHECK_ID, check_id)
// Poll for results (max 15 min = 300 polls x 3s)
let attempts = 0
while (attempts < 300) {
await new Promise(r => setTimeout(r, 3000))
const pollRes = await fetch(`/api/sdk/v1/agent/compliance-check?check_id=${check_id}`)
if (!pollRes.ok) { attempts++; continue }
const pollData = await pollRes.json()
if (pollData.progress) setProgress(pollData.progress)
if (pollData.status === 'completed' && pollData.result) {
setResults(pollData.result)
setProgress('')
localStorage.setItem(STORAGE_KEY_RESULTS, JSON.stringify(pollData.result))
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
const resultKey = `compliance-check-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
const entry: HistoryEntry = {
date: new Date().toISOString(),
docCount: entries.length,
findings: pollData.result.total_findings || 0,
resultKey,
}
const updated = [entry, ...history].slice(0, 30)
setHistory(updated)
localStorage.setItem(STORAGE_KEY_HISTORY, JSON.stringify(updated))
break
}
if (pollData.status === 'failed') {
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
}
attempts++
}
if (attempts >= 300) {
localStorage.removeItem(STORAGE_KEY_CHECK_ID); setActiveCheckId('')
throw new Error('Zeitlimit ueberschritten (15 Min)')
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setProgress('')
} finally {
setLoading(false)
}
}
const loadFromHistory = (entry: HistoryEntry) => {
if (entry.resultKey) {
try {
const saved = localStorage.getItem(entry.resultKey)
if (saved) { setResults(JSON.parse(saved)); return }
} catch { /* ignore */ }
}
try {
const last = localStorage.getItem(STORAGE_KEY_RESULTS)
if (last) setResults(JSON.parse(last))
} catch { /* ignore */ }
}
return (
<div className="space-y-4">
{/* Info box */}
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-purple-900">Compliance-Check (Alle Dokumente)</h3>
<p className="text-xs text-purple-700 mt-1">
Geben Sie die URLs Ihrer Rechtstexte ein oder laden Sie die Dokumente hoch.
Das System prueft alle Pflichtangaben nach DSGVO, TDDDG, TMG und UWG.
Pflichtdokumente sind mit * markiert.
</p>
</div>
{/* Document rows */}
<div className="space-y-2">
{DOCUMENT_TYPES.map(dt => (
<DocumentRow
key={dt.id}
label={dt.label}
docType={dt.id}
required={dt.required}
url={docs[dt.id].url}
text={docs[dt.id].text}
loading={docs[dt.id].loading}
error={docs[dt.id].error}
wordCount={countWords(docs[dt.id].text)}
onUrlChange={url => updateDoc(dt.id, { url })}
onFetchText={() => handleFetchText(dt.id)}
onTextChange={text => updateDoc(dt.id, { text })}
onFileUpload={file => handleFileUpload(dt.id, file)}
/>
))}
</div>
{/* Agent toggle + submit */}
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setUseAgent(!useAgent)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
useAgent
? 'bg-emerald-100 border-emerald-300 text-emerald-800'
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
}`}
>
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
{useAgent ? 'KI-Agent aktiv (alle MCs)' : 'KI-Agent aus'}
</button>
<span className="text-xs text-gray-500">
{filledCount} von {DOCUMENT_TYPES.length} Dokumenten ausgefuellt
</span>
</div>
{/* Submit button */}
<button
onClick={handleSubmit}
disabled={loading || filledCount === 0}
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Pruefe...
</>
) : (
`Compliance-Check starten (${filledCount} Dokument${filledCount !== 1 ? 'e' : ''})`
)}
</button>
{/* Progress */}
{progress && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{progress}
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
)}
{/* Results */}
{results && results.results && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
{/* Business Profile */}
{results.business_profile && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-xs">
<div className="font-semibold text-blue-900 mb-1">Erkanntes Geschaeftsmodell</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-blue-700">
<span>Typ: <strong>{results.business_profile.business_type?.toUpperCase()}</strong></span>
<span>Branche: {results.business_profile.industry}</span>
{results.business_profile.has_online_shop && <span className="text-amber-700">Online-Shop</span>}
{results.business_profile.is_regulated_profession && <span className="text-amber-700">Regulierter Beruf ({results.business_profile.regulated_profession_type})</span>}
</div>
</div>
)}
{/* Extracted Profile — pre-fill suggestion */}
{results.extracted_profile?.company_profile && Object.keys(results.extracted_profile.company_profile).length > 0 && (
<div className="mb-4 p-3 bg-emerald-50 border border-emerald-200 rounded-lg text-xs">
<div className="flex items-center justify-between mb-1">
<span className="font-semibold text-emerald-900">Aus Dokumenten extrahiert</span>
<button className="text-emerald-700 hover:text-emerald-900 text-xs font-medium underline"
onClick={() => { /* TODO: navigate to company profile with pre-fill */ }}>
In Company Profile uebernehmen
</button>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-emerald-700">
{results.extracted_profile.company_profile.companyName && (
<span>Firma: <strong>{results.extracted_profile.company_profile.companyName}</strong></span>
)}
{results.extracted_profile.company_profile.legalForm && (
<span>Rechtsform: {results.extracted_profile.company_profile.legalForm.toUpperCase()}</span>
)}
{results.extracted_profile.company_profile.headquartersCity && (
<span>Sitz: {results.extracted_profile.company_profile.headquartersZip} {results.extracted_profile.company_profile.headquartersCity}</span>
)}
{results.extracted_profile.company_profile.dpoEmail && (
<span>DSB: {results.extracted_profile.company_profile.dpoEmail}</span>
)}
{results.extracted_profile.company_profile.ustIdNr && (
<span>USt-IdNr: {results.extracted_profile.company_profile.ustIdNr}</span>
)}
</div>
{results.extracted_profile.compliance_scope_hints?.length > 0 && (
<div className="mt-2 pt-2 border-t border-emerald-200 text-emerald-600">
<span className="font-medium">Scope-Hinweise: </span>
{results.extracted_profile.compliance_scope_hints.map((h: any, i: number) => (
<span key={i} className="inline-block bg-emerald-100 rounded px-1.5 py-0.5 mr-1 mb-1">
{h.source}
</span>
))}
</div>
)}
</div>
)}
{/* Banner Check Result */}
{results.banner_result && (
<div className={`mb-4 p-3 rounded-lg border text-xs ${
results.banner_result.violations > 0
? 'bg-amber-50 border-amber-200'
: results.banner_result.detected
? 'bg-green-50 border-green-200'
: 'bg-gray-50 border-gray-200'
}`}>
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${
results.banner_result.violations > 0 ? 'bg-amber-500'
: results.banner_result.detected ? 'bg-green-500' : 'bg-gray-400'
}`} />
<span className="font-semibold text-gray-900">
Cookie-Banner-Check (automatisch)
</span>
</div>
<div className="mt-1 text-gray-600 ml-4">
{results.banner_result.detected ? (
<>
Banner erkannt{results.banner_result.provider ? ` (${results.banner_result.provider})` : ''}.
{results.banner_result.violations > 0
? ` ${results.banner_result.violations} Auffaelligkeit${results.banner_result.violations !== 1 ? 'en' : ''} gefunden.`
: ' Keine Auffaelligkeiten.'}
</>
) : (
'Kein Cookie-Banner erkannt oder Banner-Check nicht moeglich.'
)}
</div>
</div>
)}
<ChecklistView results={results.results} />
{/* Email status */}
{results.email_status && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
</div>
)}
</div>
)}
{/* History */}
{history.length > 0 && (
<div className="border border-gray-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Compliance-Checks</h4>
<div className="space-y-1">
{history.map((h, i) => (
<button
key={i}
onClick={() => loadFromHistory(h)}
className="w-full flex items-center justify-between text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left"
>
<span className="text-gray-600">
{new Date(h.date).toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})}
</span>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">{h.docCount} Dok.</span>
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
{h.findings} Findings
</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,145 @@
'use client'
import React, { useState } from 'react'
interface FAQItem {
q: string
a: string
}
const FAQ_ITEMS: FAQItem[] = [
{
q: "Was passiert wenn ein Unternehmen wegen unzureichender Datenschutzerklaerung oder Cookie-Richtlinie verklagt wird?",
a: `Es gibt vier Durchsetzungswege:
**1. Bussgelder durch Aufsichtsbehoerden (Art. 83 DSGVO)**
Aufsichtsbehoerden pruefen von Amts wegen oder auf Beschwerde — kein Klaeger noetig. Bussgelder bis 20 Mio. EUR oder 4% des Jahresumsatzes. Beispiele: CNIL gegen Google (150 Mio. EUR), Facebook (60 Mio. EUR), H&M (35 Mio. EUR). Auch KMU sind betroffen — der LfDI Baden-Wuerttemberg hat Bussgelder ab 10.000 EUR verhaengt.
**2. Abmahnungen durch Verbraucherschutzverbaende**
Verbaende wie vzbv oder DUH koennen ohne individuellen Schaden klagen (§2 UKlaG). Das ist der groesste praktische Druck: Unterlassungsklage + Anwaltskosten (5.000-20.000 EUR pro Fall). Seit EuGH C-319/20 (Meta/vzbv) duerfen Verbaende DSGVO-Verstoesse auch ohne Betroffenenauftrag klagen.
**3. Individueller Schadensersatz (Art. 82 DSGVO)**
Seit EuGH C-300/21 (Oesterreichische Post) genuegt bereits der "Kontrollverlust" ueber Daten als immaterieller Schaden — kein messbarer finanzieller Schaden noetig. Typisch: 100-5.000 EUR pro Betroffenem. Legaltech-Firmen wie NOYB buendeln Massenverfahren.
**4. Wettbewerber-Abmahnungen (UWG)**
Seit 2021 eingeschraenkt, aber Impressums-Maengel oder fehlende Cookie-Einwilligung bleiben abmahnfaehig.
Die Aufsichtsbehoerden erhalten ueber 10.000 Beschwerden pro Jahr. Eine Beschwerde einzureichen ist kostenlos und mit einem Klick moeglich.`,
},
{
q: "Wie funktioniert die Dokumentenpruefung?",
a: `Die Pruefung laeuft in drei Schritten:
**1. Text-Extraktion** — Playwright laedt die Seite, expandiert Accordions/Tabs und extrahiert den vollstaendigen Text.
**2. Regex-Checks (138 Pruefpunkte)** — Zwei Ebenen: L1 prueft ob Pflichtangaben erwaehnt sind (z.B. "Verantwortlicher"), L2 prueft ob sie korrekt und vollstaendig sind (z.B. "Hat der Verantwortliche eine ladungsfaehige Anschrift mit PLZ?").
**3. LLM-Verifikation** — Jeder fehlgeschlagene Check wird von einem KI-Modell (Qwen) gegen den Originaltext gegengeprueft, um Fehlalarme zu eliminieren.
Das Ergebnis: Zwei Scores pro Dokument — Vollstaendigkeit (sind alle Pflichtangaben da?) und Korrektheit (sind sie richtig formuliert?). Jeder fehlende Punkt hat eine konkrete Handlungsanweisung mit Rechtsbezug.`,
},
{
q: "Welche Dokumenttypen werden geprueft?",
a: `Sieben Dokumenttypen mit jeweils eigener Checkliste:
- **Datenschutzinformation (DSI)** — Art. 13/14 DSGVO (31 Checks)
- **Cookie-Richtlinie** — §25 TDDDG (15 Checks)
- **Impressum** — §5 TMG / §18 MStV (16 Checks)
- **AGB** — §305ff BGB (21 Checks)
- **Widerrufsbelehrung** — §355 BGB (15 Checks)
- **Social Media DSE** — Art. 26 DSGVO Joint Controller (20 Checks)
- **DSFA** — Art. 35 DSGVO (18 Checks)
Sub-Sektionen (z.B. Cookie-Abschnitt innerhalb der DSI) werden automatisch erkannt und separat geprueft.`,
},
{
q: "Wie zuverlaessig sind die Ergebnisse?",
a: `Die Pruefung wurde gegen mehrere Ground-Truth-Websites validiert (IHK Konstanz, ETO Gruppe, BMW, Stadt Koeln, Sparkasse, Spiegel u.a.). Ergebnis: **0 False Positives** bei validierten Testfaellen — jeder rote Punkt ist ein echtes Finding.
Durch die LLM-Verifikation werden Regex-Fehlalarme (z.B. durch ungewoehnliche Formatierung oder Soft Hyphens im HTML) automatisch korrigiert. Trotzdem gilt: Das Tool ersetzt keine Rechtsberatung, sondern identifiziert Handlungsbedarf.`,
},
{
q: "Was kostet ein Verstoss gegen die DSGVO in der Praxis?",
a: `Bussgelder nach Art. 83 DSGVO staffeln sich in zwei Stufen:
- **Bis 10 Mio. EUR / 2% Umsatz**: Verstoesse gegen technische/organisatorische Pflichten (Art. 25, 28, 32)
- **Bis 20 Mio. EUR / 4% Umsatz**: Verstoesse gegen Grundsaetze, Betroffenenrechte, Drittlandtransfer
Typische Praxis-Bussgelder in Deutschland: 5.000-50.000 EUR fuer KMU, 100.000-1 Mio. EUR fuer groessere Unternehmen. Dazu kommen Anwaltskosten bei Abmahnungen (5.000-20.000 EUR pro Fall) und Reputationsschaden.`,
},
{
q: "Was ist der aktuelle Stand bei harmonisierten Normen unter der neuen Maschinenverordnung (EU) 2023/1230?",
a: `Die Maschinenverordnung (EU) 2023/1230 hat in Anhang I die wesentlichen Gesundheits- und Sicherheitsanforderungen und verweist darauf, dass harmonisierte Normen die technischen Details liefern sollen (Konformitaetsvermutung).
**Aktueller Stand:** Es gibt noch KEINE harmonisierten Normen die unter der neuen Maschinenverordnung im EU-Amtsblatt veroeffentlicht sind. Die bestehenden ~800 harmonisierten Normen gelten noch unter der alten Maschinenrichtlinie 2006/42/EC.
**Zeitplan:**
- **Juni 2023** — Maschinenverordnung veroeffentlicht
- **Januar 2025** — EU-Kommission hat Normungsauftrag an CEN/CENELEC erteilt
- **Januar 2026** — CEN/CENELEC soll bestehende Normen bestaetigen oder Nachfolgenormen verabschieden
- **Januar 2027** — Maschinenverordnung tritt vollstaendig in Kraft, ersetzt alte Richtlinie 2006/42/EC
**Wichtig fuer Hersteller:** Bis die neuen harmonisierten Normen veroeffentlicht sind, koennen Hersteller die bestehenden Normen der alten Maschinenrichtlinie weiterhin anwenden. Nach dem 20. Januar 2027 muessen Maschinen aber die Anforderungen der neuen Verordnung erfuellen — auch wenn die harmonisierten Normen bis dahin nicht vollstaendig vorliegen.
**IACE Normen-Bibliothek:** Die aktuelle Liste unter /sdk/iace/library enthaelt 751 harmonisierte Normen (1 A-Norm, 19 B1, 126 B2, 605 C-Normen). Diese muessen regelmaessig gegen das EU-Amtsblatt abgeglichen werden, da einige Normen zurueckgezogen oder ersetzt wurden.`,
},
{
q: "Warum muss ich harmonisierte Normen kaufen obwohl sie EU-Recht sind?",
a: `Harmonisierte Normen werden von privaten Organisationen (CEN/CENELEC) erstellt und ueber nationale Normungsinstitute wie DIN/Beuth (Deutschland), ASI (Oesterreich) oder SNV (Schweiz) verkauft — typisch 50-300 EUR pro Norm.
**Das Problem:** Die EU-Kommission beauftragt die Normung, Industrieexperten schreiben die Normen ehrenamtlich in Technischen Komitees, aber ein privater Verlag verkauft das Ergebnis. Unternehmen muessen Normen kaufen die ihre eigenen Mitarbeiter geschrieben haben.
**EuGH-Urteil C-588/21 P (5. Maerz 2024):**
Der Europaeische Gerichtshof hat entschieden, dass harmonisierte Normen **Teil des EU-Rechts** sind, weil sie eine Konformitaetsvermutung erzeugen. Das Rechtsstaatsprinzip verlangt, dass Buerger die Regeln kennen koennen die fuer sie gelten. Daher muessen harmonisierte Normen grundsaetzlich **frei zugaenglich** sein.
**Aktueller Stand (2026):** Das Urteil ist noch nicht vollstaendig umgesetzt. CEN/CENELEC und die nationalen Normungsinstitute wehren sich, weil ihr Geschaeftsmodell auf dem Verkauf basiert. Die EU-Kommission arbeitet an einer Loesung.
**Was das fuer Unternehmen bedeutet:**
- Aktuell muessen Normen weiterhin gekauft werden
- Normnummern und Titel sind frei nutzbar (bibliographische Daten)
- BSI-Grundschutz und NIST-Standards sind kostenlose Alternativen die inhaltlich aehnliche Anforderungen abdecken
- Die IACE-Bibliothek in BreakPilot listet alle harmonisierten Normen mit Status (aktiv/zurueckgezogen) ohne kostenpflichtigen Normtext`,
},
]
export function ComplianceFAQ() {
const [open, setOpen] = useState<number | null>(null)
return (
<div className="border border-gray-200 rounded-xl overflow-hidden">
<div className="px-4 py-3 bg-gray-50 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-800">Haeufige Fragen</h3>
</div>
<div className="divide-y divide-gray-100">
{FAQ_ITEMS.map((item, i) => (
<div key={i}>
<button
onClick={() => setOpen(open === i ? null : i)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition-colors"
>
<span className="text-sm font-medium text-gray-900 pr-4">{item.q}</span>
<svg
className={`w-4 h-4 text-gray-400 shrink-0 transition-transform ${open === i ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{open === i && (
<div className="px-4 pb-4 text-sm text-gray-600 prose prose-sm max-w-none">
{item.a.split('\n\n').map((para, pi) => (
<p key={pi} className="mb-2 last:mb-0" dangerouslySetInnerHTML={{
__html: para
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\n- /g, '<br/>• ')
.replace(/\n/g, '<br/>')
}} />
))}
</div>
)}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,248 @@
'use client'
import React from 'react'
interface Violation {
service: string
severity: string
text: string
legal_ref: string
}
interface PhaseData {
scripts: string[]
cookies: string[]
tracking_services?: string[]
new_tracking?: string[]
violations?: Violation[]
undocumented?: string[]
}
interface ConsentData {
banner_detected: boolean
banner_provider: string
phases: {
before_consent: PhaseData
after_reject: PhaseData
after_accept: PhaseData
}
summary: {
critical: number
high: number
undocumented: number
total_violations: number
category_violations?: number
categories_tested?: number
}
banner_checks?: {
has_impressum_link: boolean
has_dse_link: boolean
violations: { service: string; severity: string; text: string; legal_ref: string }[]
}
category_tests?: {
category: string
category_label: string
tracking_services: string[]
violations: { service: string; severity: string; text: string }[]
}[]
}
const SEV = {
CRITICAL: { bg: 'bg-red-100 border-red-300', text: 'text-red-800', badge: 'bg-red-600' },
HIGH: { bg: 'bg-orange-100 border-orange-300', text: 'text-orange-800', badge: 'bg-orange-500' },
}
function PhaseCard({ title, icon, data, type }: {
title: string; icon: string; data: PhaseData; type: 'before' | 'reject' | 'accept'
}) {
const violations = data.violations || []
const tracking = data.tracking_services || data.new_tracking || []
const undocumented = data.undocumented || []
const hasProblem = violations.length > 0 || undocumented.length > 0
return (
<div className={`border rounded-lg p-4 ${hasProblem ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50'}`}>
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-2">
<span>{icon}</span> {title}
</h4>
{/* Violations */}
{violations.map((v, i) => (
<div key={i} className={`mb-2 p-2 rounded border ${SEV[v.severity as keyof typeof SEV]?.bg || SEV.HIGH.bg}`}>
<div className="flex items-center gap-2">
<span className={`text-[10px] px-1.5 py-0.5 rounded text-white ${SEV[v.severity as keyof typeof SEV]?.badge || SEV.HIGH.badge}`}>
{v.severity}
</span>
<span className={`text-xs font-medium ${SEV[v.severity as keyof typeof SEV]?.text || SEV.HIGH.text}`}>
{v.service}
</span>
</div>
<p className="text-xs text-gray-700 mt-1">{v.text}</p>
<p className="text-[10px] text-gray-500 mt-0.5">{v.legal_ref}</p>
</div>
))}
{/* Undocumented (Phase C only) */}
{undocumented.map((s, i) => (
<div key={i} className="mb-2 p-2 rounded border border-yellow-300 bg-yellow-50">
<span className="text-xs text-yellow-800"> {s} nicht in Cookie-Policy dokumentiert</span>
</div>
))}
{/* Tracking services (no violations) */}
{violations.length === 0 && undocumented.length === 0 && tracking.length > 0 && (
<div className="text-xs text-green-700">
{tracking.map((t, i) => <div key={i}> {t} {type === 'accept' ? 'mit Consent OK' : 'erkannt'}</div>)}
</div>
)}
{violations.length === 0 && undocumented.length === 0 && tracking.length === 0 && (
<p className="text-xs text-green-700"> Keine Tracking-Dienste erkannt</p>
)}
{/* Cookie/Script count */}
<div className="flex gap-3 mt-2 text-[10px] text-gray-400">
<span>{data.scripts?.length || 0} Scripts</span>
<span>{data.cookies?.length || 0} Cookies</span>
</div>
</div>
)
}
export function ConsentTestResult({ data }: { data: ConsentData }) {
const s = data.summary
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className={`w-3 h-3 rounded-full ${data.banner_detected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm font-medium text-gray-900">
Cookie-Banner: {data.banner_detected ? data.banner_provider : 'Nicht erkannt'}
</span>
</div>
<div className="flex gap-2">
{s.critical > 0 && (
<span className="text-xs px-2 py-1 rounded bg-red-600 text-white font-medium">
{s.critical} Kritisch
</span>
)}
{s.high > 0 && (
<span className="text-xs px-2 py-1 rounded bg-orange-500 text-white font-medium">
{s.high} Hoch
</span>
)}
{s.total_violations === 0 && (
<span className="text-xs px-2 py-1 rounded bg-green-500 text-white font-medium">
Keine Verstoesse
</span>
)}
</div>
</div>
{/* Three Phases */}
<div className="space-y-3">
<PhaseCard
title="Phase A: Vor Einwilligung"
icon="🔍"
data={data.phases.before_consent}
type="before"
/>
{data.banner_detected && (
<>
<PhaseCard
title="Phase B: Nach Ablehnung"
icon="🚫"
data={data.phases.after_reject}
type="reject"
/>
<PhaseCard
title="Phase C: Nach Zustimmung"
icon="✅"
data={data.phases.after_accept}
type="accept"
/>
</>
)}
</div>
{/* Banner Text Checks */}
{data.banner_checks && (data.banner_checks.violations?.length > 0 || data.banner_checks.has_impressum_link !== undefined) && (
<div className="border rounded-lg p-4 border-gray-200 bg-gray-50">
<h4 className="text-sm font-semibold text-gray-900 mb-3 flex items-center gap-2">
<span>📝</span> Banner-Text Pruefung
</h4>
<div className="flex gap-3 mb-3 text-xs">
<span className={data.banner_checks.has_impressum_link ? 'text-green-600' : 'text-red-600'}>
{data.banner_checks.has_impressum_link ? '✓' : '✗'} Impressum-Link
</span>
<span className={data.banner_checks.has_dse_link ? 'text-green-600' : 'text-red-600'}>
{data.banner_checks.has_dse_link ? '✓' : '✗'} DSE-Link
</span>
</div>
{data.banner_checks.violations?.map((v: any, i: number) => {
const isHigh = v.severity === 'HIGH' || v.severity === 'CRITICAL'
return (
<div key={i} className={`mb-2 p-2 rounded border ${isHigh ? 'border-red-300 bg-red-50' : 'border-yellow-300 bg-yellow-50'}`}>
<div className="flex items-start gap-2">
<span className={`text-[10px] px-1.5 py-0.5 rounded text-white ${isHigh ? 'bg-red-600' : 'bg-yellow-600'}`}>
{v.severity}
</span>
<div>
<p className="text-xs text-gray-800">{v.text}</p>
<p className="text-[10px] text-gray-500 mt-0.5">{v.legal_ref}</p>
</div>
</div>
</div>
)
})}
{(!data.banner_checks.violations || data.banner_checks.violations.length === 0) && (
<p className="text-xs text-green-700"> Keine Banner-Text-Verstoesse erkannt</p>
)}
</div>
)}
{/* Category Tests (Phase D-F) */}
{data.category_tests && data.category_tests.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-gray-900 mt-2">Kategorie-Tests ({data.category_tests.length})</h4>
{data.category_tests.map((ct, i) => {
const hasViolations = ct.violations.length > 0
return (
<div key={i} className={`border rounded-lg p-4 ${hasViolations ? 'border-red-200 bg-red-50' : 'border-green-200 bg-green-50'}`}>
<h4 className="text-sm font-semibold text-gray-900 mb-2 flex items-center gap-2">
<span>🔀</span> Nur &quot;{ct.category_label}&quot;
</h4>
{ct.violations.length > 0 ? (
ct.violations.map((v, vi) => (
<div key={vi} className="mb-2 p-2 rounded border border-red-300 bg-red-100">
<span className="text-xs font-bold text-red-800 px-1.5 py-0.5 rounded bg-red-200">FALSCH</span>
<span className="text-xs text-red-700 ml-2">{v.text}</span>
</div>
))
) : (
<div className="text-xs text-green-700">
{ct.tracking_services.length > 0 ? (
ct.tracking_services.map((s, si) => <div key={si}> {s} korrekte Kategorie</div>)
) : (
<div> Keine Tracking-Dienste geladen korrekt</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
{/* No banner warning */}
{!data.banner_detected && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-xs text-red-700">
<strong>Kein Cookie-Banner erkannt.</strong> Alle erkannten Tracking-Dienste laden ohne
Einwilligung dies ist ein Verstoss gegen §25 TDDDG.
</div>
)}
</div>
)
}
@@ -0,0 +1,320 @@
'use client'
import React, { useState } from 'react'
import { ChecklistView } from './ChecklistView'
interface DocEntry {
id: string
type: string
label: string
url: string
}
const DOC_TYPES = [
{ id: 'dse', label: 'DSI (Datenschutzinformation)' },
{ id: 'social_media', label: 'DSE Social Media (Art. 26)' },
{ id: 'dsfa', label: 'DSFA (Art. 35)' },
{ id: 'agb', label: 'AGB / Nutzungsbedingungen' },
{ id: 'impressum', label: 'Impressum' },
{ id: 'cookie', label: 'Cookie-Richtlinie' },
{ id: 'widerruf', label: 'Widerrufsbelehrung' },
{ id: 'other', label: 'Sonstiges' },
]
function newEntry(): DocEntry {
return { id: crypto.randomUUID().slice(0, 8), type: 'dse', label: '', url: '' }
}
export function DocCheckTab() {
const [entries, setEntries] = useState<DocEntry[]>(() => {
if (typeof window === 'undefined') return [newEntry()]
try { const s = localStorage.getItem('doc-check-entries'); return s ? JSON.parse(s) : [newEntry()] } catch { return [newEntry()] }
})
const [checkCookieBanner, setCheckCookieBanner] = useState(false)
const [useAgent, setUseAgent] = useState(false)
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState('')
const [results, setResults] = useState<any>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem('doc-check-results'); return s ? JSON.parse(s) : null } catch { return null }
})
const [error, setError] = useState<string | null>(null)
const [history, setHistory] = useState<{ date: string; urls: number; findings: number; resultKey: string }[]>(() => {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem('doc-check-history') || '[]') } catch { return [] }
})
// Persist entries
React.useEffect(() => { localStorage.setItem('doc-check-entries', JSON.stringify(entries)) }, [entries])
const updateEntry = (id: string, field: keyof DocEntry, value: string) => {
setEntries(prev => prev.map(e => e.id === id ? { ...e, [field]: value } : e))
}
const removeEntry = (id: string) => {
setEntries(prev => prev.filter(e => e.id !== id))
}
const addEntry = () => {
setEntries(prev => [...prev, newEntry()])
}
// Auto-detect label from URL
const autoLabel = (entry: DocEntry) => {
if (entry.label) return
try {
const path = new URL(entry.url).pathname
const last = path.split('/').filter(Boolean).pop() || ''
const label = last.replace(/-\d+$/, '').replace(/-/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase())
if (label.length > 3) {
updateEntry(entry.id, 'label', label)
}
} catch { /* invalid URL */ }
}
const handleSubmit = async () => {
const validEntries = entries.filter(e => e.url.trim())
if (validEntries.length === 0) return
setLoading(true)
setError(null)
setResults(null)
setProgress('Pruefung wird gestartet...')
try {
const startRes = await fetch('/api/sdk/v1/agent/doc-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entries: validEntries.map(e => ({
doc_type: e.type,
label: e.label || e.url.split('/').pop() || 'Dokument',
url: e.url.trim(),
})),
check_cookie_banner: checkCookieBanner,
use_agent: useAgent,
}),
})
if (!startRes.ok) throw new Error(`Pruefung konnte nicht gestartet werden: ${startRes.status}`)
const { check_id } = await startRes.json()
if (!check_id) throw new Error('Keine Check-ID erhalten')
// Poll for results
let attempts = 0
while (attempts < 120) {
await new Promise(r => setTimeout(r, 3000))
const pollRes = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
if (!pollRes.ok) { attempts++; continue }
const pollData = await pollRes.json()
if (pollData.progress) setProgress(pollData.progress)
if (pollData.status === 'completed' && pollData.result) {
setResults(pollData.result)
setProgress('')
localStorage.setItem('doc-check-results', JSON.stringify(pollData.result))
const resultKey = `doc-check-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch { /* quota */ }
const entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0, resultKey }
const updated = [entry, ...history].slice(0, 30)
setHistory(updated)
localStorage.setItem('doc-check-history', JSON.stringify(updated))
break
}
if (pollData.status === 'failed') {
throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
}
attempts++
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setProgress('')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4">
{/* URL Entries */}
<div className="space-y-2">
{entries.map((entry, i) => (
<div key={entry.id} className="flex items-center gap-2">
<select
value={entry.type}
onChange={e => updateEntry(entry.id, 'type', e.target.value)}
className="w-48 px-3 py-2.5 border border-gray-300 rounded-lg text-sm bg-white shrink-0"
>
{DOC_TYPES.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
<input
type="text"
value={entry.label}
onChange={e => updateEntry(entry.id, 'label', e.target.value)}
placeholder={entry.type === 'other' ? 'Dokumentname' : 'Version / Stand (optional)'}
className="w-40 px-3 py-2.5 border border-gray-300 rounded-lg text-sm shrink-0"
/>
<input
type="url"
value={entry.url}
onChange={e => updateEntry(entry.id, 'url', e.target.value)}
onBlur={() => autoLabel(entry)}
placeholder="https://example.com/datenschutz"
className="flex-1 px-3 py-2.5 border border-gray-300 rounded-lg text-sm"
/>
{entries.length > 1 && (
<button onClick={() => removeEntry(entry.id)}
className="p-2 text-gray-400 hover:text-red-500 shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
))}
</div>
{/* Add URL + Options */}
<div className="flex items-center justify-between">
<button onClick={addEntry}
className="flex items-center gap-1.5 text-sm text-purple-600 hover:text-purple-700 font-medium">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
URL hinzufuegen
</button>
<label className="flex items-center gap-2 text-sm text-gray-600">
<input
type="checkbox"
checked={checkCookieBanner}
onChange={e => setCheckCookieBanner(e.target.checked)}
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
Cookie-Banner pruefen
</label>
<button
type="button"
onClick={() => setUseAgent(!useAgent)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
useAgent
? 'bg-emerald-100 border-emerald-300 text-emerald-800'
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
}`}
>
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
{useAgent ? 'KI-Agent aktiv (1.874 MCs)' : 'KI-Agent aus'}
</button>
</div>
{/* Submit */}
<button
onClick={handleSubmit}
disabled={loading || entries.every(e => !e.url.trim())}
className="w-full px-4 py-3 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 disabled:opacity-50 transition-colors text-sm flex items-center justify-center gap-2"
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Pruefe...
</>
) : (
`${entries.filter(e => e.url.trim()).length} Dokument${entries.filter(e => e.url.trim()).length !== 1 ? 'e' : ''} pruefen`
)}
</button>
{/* Progress */}
{progress && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm text-purple-700 flex items-center gap-3">
<svg className="animate-spin w-4 h-4 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{progress}
</div>
)}
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">{error}</div>
)}
{/* Results */}
{results && results.results && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<ChecklistView results={results.results} />
{/* Cookie Banner Result */}
{results.cookie_banner_result && (
<div className="mt-4 pt-4 border-t border-gray-200">
<h4 className="text-sm font-semibold text-gray-800 mb-2">Cookie-Banner</h4>
<div className="text-sm text-gray-600">
{results.cookie_banner_result.banner_detected
? `Banner erkannt: ${results.cookie_banner_result.banner_provider || 'unbekannt'}`
: 'Kein Banner erkannt'}
</div>
{results.cookie_banner_result.banner_checks?.violations?.length > 0 && (
<div className="mt-2 space-y-1">
{results.cookie_banner_result.banner_checks.violations.map((v: any, i: number) => (
<div key={i} className="text-xs text-red-600 flex items-start gap-1.5">
<span className="shrink-0 mt-0.5">!!</span>
<span>{v.text}</span>
</div>
))}
</div>
)}
</div>
)}
{/* Email Status */}
{results.email_status && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
</div>
)}
</div>
)}
{/* History */}
{history.length > 0 && (
<div className="border border-gray-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Pruefungen</h4>
<div className="space-y-1">
{history.map((h, i) => (
<button key={i} onClick={() => {
if (h.resultKey) {
try {
const saved = localStorage.getItem(h.resultKey)
if (saved) { setResults(JSON.parse(saved)); return }
} catch {}
}
// Fallback: load last result
try {
const last = localStorage.getItem('doc-check-results')
if (last) setResults(JSON.parse(last))
} catch {}
}}
className="w-full flex items-center justify-between text-sm py-2 px-2 rounded-lg border border-gray-50 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
<span className="text-gray-600">
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</span>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-500">{h.urls} Dok.</span>
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-amber-600' : 'text-green-600'}`}>
{h.findings} Findings
</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,163 @@
'use client'
import React, { useState, useRef } from 'react'
interface DocumentRowProps {
label: string
docType: string
required?: boolean
url: string
text: string
loading: boolean
error: string | null
wordCount: number
onUrlChange: (url: string) => void
onFetchText: () => void
onTextChange: (text: string) => void
onFileUpload: (file: File) => void
}
export function DocumentRow({
label,
docType,
required,
url,
text,
loading,
error,
wordCount,
onUrlChange,
onFetchText,
onTextChange,
onFileUpload,
}: DocumentRowProps) {
const [showText, setShowText] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const textVisible = showText || text.length > 0
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Read text-based files directly
const reader = new FileReader()
reader.onload = () => {
const content = reader.result as string
onTextChange(content)
}
reader.onerror = () => {
// Let parent handle via onFileUpload for binary formats
onFileUpload(file)
}
if (file.name.endsWith('.txt') || file.type === 'text/plain') {
reader.readAsText(file)
} else {
// PDF, DOCX — pass to parent for server-side parsing
onFileUpload(file)
}
// Reset input so the same file can be re-selected
e.target.value = ''
}
return (
<div className="border border-gray-200 rounded-lg p-3 space-y-2">
{/* Header row: label + inputs */}
<div className="flex items-center gap-2">
<div className="w-52 shrink-0">
<span className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-0.5">*</span>}
</span>
</div>
<input
type="url"
value={url}
onChange={e => onUrlChange(e.target.value)}
placeholder="https://example.com/datenschutz"
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
{/* Fetch text button */}
<button
type="button"
onClick={onFetchText}
disabled={loading || !url.trim()}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap transition-colors"
>
{loading ? (
<svg className="animate-spin w-4 h-4 text-purple-500" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : (
'Text laden'
)}
</button>
{/* File upload button */}
<button
type="button"
onClick={() => fileRef.current?.click()}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-700 hover:bg-gray-50 transition-colors"
title="PDF, DOCX oder TXT hochladen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</button>
<input
ref={fileRef}
type="file"
accept=".pdf,.docx,.doc,.txt"
onChange={handleFileChange}
className="hidden"
/>
{/* Toggle text area */}
<button
type="button"
onClick={() => setShowText(!showText)}
className={`px-3 py-2 border rounded-lg text-sm transition-colors ${
textVisible
? 'border-purple-300 bg-purple-50 text-purple-700'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
title={textVisible ? 'Text ausblenden' : 'Text anzeigen'}
>
<svg className={`w-4 h-4 transition-transform ${textVisible ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Word count badge */}
{wordCount > 0 && (
<span className="text-xs px-2 py-1 rounded-full bg-green-100 text-green-700 font-medium shrink-0">
{wordCount.toLocaleString('de-DE')} W.
</span>
)}
</div>
{/* Error */}
{error && (
<div className="text-xs text-red-600 px-1">{error}</div>
)}
{/* Collapsible textarea */}
{textVisible && (
<textarea
value={text}
onChange={e => onTextChange(e.target.value)}
placeholder="Dokumenttext hier einfuegen oder per URL / Upload laden..."
rows={6}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono resize-y focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
)}
</div>
)
}
@@ -0,0 +1,181 @@
'use client'
import React, { useState } from 'react'
import { ChecklistView } from './ChecklistView'
interface CheckItem {
id: string; label: string; passed: boolean; severity: string
matched_text: string; level?: number; parent?: string | null
skipped?: boolean; hint?: string
}
export function ImpressumCheckTab() {
const [url, setUrl] = useState(() =>
typeof window !== 'undefined' ? localStorage.getItem('impressum-check-url') || '' : ''
)
const [loading, setLoading] = useState(false)
const [progress, setProgress] = useState('')
const [error, setError] = useState<string | null>(null)
const [results, setResults] = useState<any>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem('impressum-check-results'); return s ? JSON.parse(s) : null } catch { return null }
})
const [history, setHistory] = useState<{ url: string; date: string; findings: number; pct: number; resultKey: string }[]>(() => {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem('impressum-check-history') || '[]') } catch { return [] }
})
const [useAgent, setUseAgent] = useState(false)
React.useEffect(() => { localStorage.setItem('impressum-check-url', url) }, [url])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!url.trim()) return
setLoading(true)
setError(null)
setResults(null)
setProgress('Impressum wird geprueft...')
try {
const startRes = await fetch('/api/sdk/v1/agent/doc-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entries: [{ doc_type: 'impressum', label: 'Impressum', url: url.trim() }],
recipient: 'dsb@breakpilot.local',
use_agent: useAgent,
}),
})
if (!startRes.ok) throw new Error(`Fehler: ${startRes.status}`)
const { check_id } = await startRes.json()
if (!check_id) throw new Error('Keine Check-ID erhalten')
let attempts = 0
while (attempts < 120) {
await new Promise(r => setTimeout(r, 3000))
const pollRes = await fetch(`/api/sdk/v1/agent/doc-check?check_id=${check_id}`)
if (!pollRes.ok) { attempts++; continue }
const pollData = await pollRes.json()
if (pollData.progress) setProgress(pollData.progress)
if (pollData.status === 'completed' && pollData.result) {
setResults(pollData.result)
setProgress('')
localStorage.setItem('impressum-check-results', JSON.stringify(pollData.result))
const resultKey = `impressum-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(pollData.result)) } catch {}
const total = pollData.result.total_findings || 0
const pct = pollData.result.results?.[0]?.completeness_pct || 0
const entry = { url: url.trim(), date: new Date().toISOString(), findings: total, pct, resultKey }
const updated = [entry, ...history].slice(0, 30)
setHistory(updated)
localStorage.setItem('impressum-check-history', JSON.stringify(updated))
break
}
if (pollData.status === 'failed') throw new Error(pollData.error || 'Pruefung fehlgeschlagen')
attempts++
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
setProgress('')
} finally {
setLoading(false)
}
}
return (
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-amber-900">Impressum-Check (§5 TMG / §18 MStV)</h3>
<p className="text-xs text-amber-700 mt-1">
Prueft 16 Pflichtangaben: Anbietername, Anschrift, Kontaktdaten, Handelsregister,
USt-IdNr., Vertretungsberechtigte, V.i.S.d.P., Streitbeilegung.
</p>
</div>
<div className="flex items-center gap-3">
<button type="button" onClick={() => setUseAgent(!useAgent)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
useAgent ? 'bg-emerald-100 border-emerald-300 text-emerald-800' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-gray-100'
}`}>
<span className={`w-2 h-2 rounded-full ${useAgent ? 'bg-emerald-500' : 'bg-gray-300'}`} />
{useAgent ? 'KI-Agent aktiv (75 MCs)' : 'KI-Agent aus'}
</button>
</div>
<form onSubmit={handleSubmit} className="flex gap-3">
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
placeholder="https://www.example.com/impressum"
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
disabled={loading} required />
<button type="submit" disabled={loading || !url.trim()}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
{loading ? (
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>Pruefe...</>
) : 'Impressum pruefen'}
</button>
</form>
{progress && (
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3">
<svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{progress}
</div>
)}
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{error}</div>}
{results?.results && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<ChecklistView results={results.results} />
{results.email_status && (
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
</div>
)}
</div>
)}
{history.length > 0 && (
<div className="border border-gray-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Letzte Impressum-Checks</h4>
<div className="space-y-1">
{history.map((h, i) => (
<button key={i} onClick={() => {
setUrl(h.url)
if (h.resultKey) {
try { const s = localStorage.getItem(h.resultKey); if (s) { setResults(JSON.parse(s)); return } } catch {}
}
try { const l = localStorage.getItem('impressum-check-results'); if (l) setResults(JSON.parse(l)) } catch {}
}}
className="w-full flex items-center justify-between p-2.5 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">{h.url}</div>
<div className="text-xs text-gray-500">
{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>
{h.findings} Findings
</span>
<span className={`text-xs font-medium ${h.pct === 100 ? 'text-green-700' : h.pct >= 50 ? 'text-yellow-700' : 'text-red-700'}`}>
{h.pct}%
</span>
</div>
</button>
))}
</div>
</div>
)}
</div>
)
}
@@ -1,6 +1,7 @@
'use client'
import React, { useState } from 'react'
import { TextReference } from './TextReference'
interface ServiceInfo {
name: string
@@ -14,11 +15,27 @@ interface ServiceInfo {
status: string
}
interface TextRef {
found: boolean
source_url: string
document_type: string
section_heading: string
section_number: string
parent_section: string
paragraph_index: number
original_text: string
issue: string
correction_type: string
correction_text: string
insert_after: string
}
interface ScanFinding {
code: string
severity: string
text: string
correction: string
text_reference: TextRef | null
}
interface ScanData {
@@ -26,6 +43,7 @@ interface ScanData {
pages_list: string[]
services: ServiceInfo[]
findings: ScanFinding[]
discovered_documents?: DiscoveredDocument[]
ai_detected: boolean
chatbot_detected: boolean
chatbot_provider: string
@@ -34,24 +52,38 @@ interface ScanData {
}
const STATUS_ICON: Record<string, { icon: string; color: string }> = {
ok: { icon: '', color: 'text-green-600' },
undocumented: { icon: '', color: 'text-red-600' },
ok: { icon: '\u2713', color: 'text-green-600' },
undocumented: { icon: '\u2717', color: 'text-red-600' },
outdated: { icon: '~', color: 'text-yellow-600' },
}
const SEV_STYLE: Record<string, { bg: string; text: string }> = {
HIGH: { bg: 'bg-red-50 border-red-200', text: 'text-red-800' },
MEDIUM: { bg: 'bg-yellow-50 border-yellow-200', text: 'text-yellow-800' },
LOW: { bg: 'bg-blue-50 border-blue-200', text: 'text-blue-800' },
const SEV_STYLE: Record<string, { bg: string; text: string; dot: string }> = {
HIGH: { bg: 'bg-red-50 border-red-200', text: 'text-red-800', dot: 'bg-red-500' },
MEDIUM: { bg: 'bg-yellow-50 border-yellow-200', text: 'text-yellow-800', dot: 'bg-yellow-500' },
LOW: { bg: 'bg-blue-50 border-blue-200', text: 'text-blue-800', dot: 'bg-blue-500' },
CRITICAL: { bg: 'bg-red-100 border-red-300', text: 'text-red-900', dot: 'bg-red-700' },
}
export function ScanResult({ data }: { data: ScanData }) {
const [expandedCorrection, setExpandedCorrection] = useState<string | null>(null)
const [expandedDoc, setExpandedDoc] = useState<string | null>(null)
const undocCount = data.services.filter(s => s.status === 'undocumented').length
const okCount = data.services.filter(s => s.status === 'ok').length
const outdatedCount = data.services.filter(s => s.status === 'outdated').length
const highCount = data.findings.filter(f => f.severity === 'HIGH').length
const highCount = data.findings.filter(f => f.severity === 'HIGH' || f.severity === 'CRITICAL').length
const docs = data.discovered_documents || []
// Group findings by doc_title
const docFindings: Record<string, ScanFinding[]> = {}
const generalFindings: ScanFinding[] = []
for (const f of data.findings) {
if (f.doc_title) {
if (!docFindings[f.doc_title]) docFindings[f.doc_title] = []
docFindings[f.doc_title].push(f)
} else {
generalFindings.push(f)
}
}
return (
<div className="space-y-5">
@@ -59,7 +91,7 @@ export function ScanResult({ data }: { data: ScanData }) {
<div className="grid grid-cols-4 gap-3">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-gray-900">{data.pages_scanned}</p>
<p className="text-xs text-gray-500">Seiten gescannt</p>
<p className="text-xs text-gray-500">Seiten</p>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-green-700">{okCount}</p>
@@ -69,9 +101,9 @@ export function ScanResult({ data }: { data: ScanData }) {
<p className="text-2xl font-bold text-red-700">{undocCount}</p>
<p className="text-xs text-gray-500">Nicht in DSE</p>
</div>
<div className="bg-yellow-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-yellow-700">{outdatedCount}</p>
<p className="text-xs text-gray-500">Veraltet</p>
<div className="bg-purple-50 rounded-lg p-3 text-center">
<p className="text-2xl font-bold text-purple-700">{docs.length}</p>
<p className="text-xs text-gray-500">Dokumente</p>
</div>
</div>
@@ -79,14 +111,14 @@ export function ScanResult({ data }: { data: ScanData }) {
{data.pages_list?.length > 0 && (
<details className="text-sm">
<summary className="text-gray-600 cursor-pointer hover:text-gray-800">
{data.pages_scanned} Seiten gescannt Details anzeigen
{data.pages_scanned} Seiten gescannt
</summary>
<ul className="mt-2 space-y-1 ml-4">
{data.pages_list.map((p, i) => {
const isMissing = data.missing_pages[p]
return (
<li key={i} className={`text-xs ${isMissing ? 'text-red-600' : 'text-gray-500'}`}>
{isMissing ? '' : ''} {p} {isMissing ? `(HTTP ${data.missing_pages[p]})` : ''}
{isMissing ? '\u2717' : '\u2713'} {p}
</li>
)
})}
@@ -94,61 +126,127 @@ export function ScanResult({ data }: { data: ScanData }) {
</details>
)}
{/* AI / Chatbot Detection */}
<div className="flex gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${data.ai_detected ? 'bg-purple-100 text-purple-800' : 'bg-gray-100 text-gray-600'}`}>
{data.ai_detected ? 'KI erkannt' : 'Keine KI erkannt'}
</span>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${data.chatbot_detected ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-600'}`}>
{data.chatbot_detected ? `Chatbot: ${data.chatbot_provider}` : 'Kein Chatbot'}
</span>
</div>
{/* Services Table */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Dienstleister-Abgleich (SOLL/IST)</h4>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Status</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Dienst</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Land</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">EU</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">In DSE</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.services.map((s, i) => {
const st = STATUS_ICON[s.status] || STATUS_ICON.ok
return (
<tr key={i} className={s.status === 'undocumented' ? 'bg-red-50' : ''}>
<td className={`px-3 py-2 font-bold ${st.color}`}>{st.icon}</td>
<td className="px-3 py-2">
<span className="font-medium text-gray-900">{s.name}</span>
<span className="text-gray-400 text-xs ml-2">{s.category}</span>
</td>
<td className="px-3 py-2 text-gray-600">{s.country}</td>
<td className="px-3 py-2">{s.eu_adequate ? '' : '✗'}</td>
<td className="px-3 py-2">{s.in_dse ? 'Ja' : <span className="text-red-600 font-medium">Nein</span>}</td>
</tr>
)
})}
</tbody>
</table>
{data.services.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Dienstleister (SOLL/IST)</h4>
<div className="border rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Status</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Dienst</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Land</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">In DSE</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{data.services.map((s, i) => {
const st = STATUS_ICON[s.status] || STATUS_ICON.ok
return (
<tr key={i} className={s.status === 'undocumented' ? 'bg-red-50' : ''}>
<td className={`px-3 py-2 font-bold ${st.color}`}>{st.icon}</td>
<td className="px-3 py-2">
<span className="font-medium text-gray-900">{s.name}</span>
<span className="text-gray-400 text-xs ml-2">{s.provider}</span>
</td>
<td className="px-3 py-2 text-gray-600">{s.country}</td>
<td className="px-3 py-2">{s.in_dse ? '\u2713' : <span className="text-red-600 font-medium">Nein</span>}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* Findings */}
{data.findings.length > 0 && (
{/* === Document-Centric View === */}
{docs.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">
Findings ({data.findings.length}, davon {highCount} kritisch)
Rechtliche Dokumente ({docs.length})
</h4>
<div className="space-y-2">
{data.findings.map((f, i) => {
{docs.map((doc, i) => {
const isExpanded = expandedDoc === doc.title
const findings = docFindings[doc.title] || []
const pct = doc.completeness_pct
const barColor = pct >= 80 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-500' : 'bg-red-500'
const statusLabel = pct >= 80 ? 'OK' : pct >= 50 ? 'Lueckenhaft' : 'Mangelhaft'
const statusColor = pct >= 80 ? 'text-green-700 bg-green-50' : pct >= 50 ? 'text-yellow-700 bg-yellow-50' : 'text-red-700 bg-red-50'
return (
<div key={i} className="border border-gray-200 rounded-lg overflow-hidden">
<button
onClick={() => setExpandedDoc(isExpanded ? null : doc.title)}
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50/50 hover:bg-gray-50 text-left"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<svg className={`w-4 h-4 text-gray-400 transition-transform shrink-0 ${isExpanded ? 'rotate-90' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">{doc.title}</div>
<div className="text-xs text-gray-500">
{doc.word_count} Woerter
{findings.length > 0 && <span className="text-red-600 ml-2">{findings.length} Maengel</span>}
</div>
</div>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
{/* Completeness bar */}
<div className="w-20 h-2 bg-gray-200 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${statusColor}`}>
{pct}%
</span>
</div>
</button>
{isExpanded && (
<div className="px-4 py-3 border-t border-gray-100 space-y-2">
{findings.length > 0 ? (
findings.map((f, fi) => {
const sev = SEV_STYLE[f.severity] || SEV_STYLE.MEDIUM
return (
<div key={fi} className="flex items-start gap-2 text-sm">
<span className={`w-2 h-2 rounded-full mt-1.5 shrink-0 ${sev.dot}`} />
<span className="text-gray-700">{f.text}</span>
</div>
)
})
) : (
<p className="text-sm text-green-600">Alle Pflichtangaben vorhanden.</p>
)}
{doc.url && (
<a href={doc.url} target="_blank" rel="noopener noreferrer"
className="text-xs text-purple-600 hover:underline mt-2 inline-block">
Dokument oeffnen
</a>
)}
</div>
)}
</div>
)
})}
</div>
</div>
)}
{/* General Findings (not associated with a specific document) */}
{generalFindings.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">
Allgemeine Findings ({generalFindings.length})
</h4>
<div className="space-y-2">
{generalFindings.map((f, i) => {
const sev = SEV_STYLE[f.severity] || SEV_STYLE.MEDIUM
const isExpanded = expandedCorrection === f.code
const corrKey = `gen-${i}`
const isExp = expandedCorrection === corrKey
return (
<div key={i} className={`border rounded-lg p-3 ${sev.bg}`}>
<div className="flex items-start gap-2">
@@ -157,22 +255,22 @@ export function ScanResult({ data }: { data: ScanData }) {
</span>
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
</div>
{f.correction && (
{/* Text Reference (original text + position + correction) */}
{f.text_reference && (
<TextReference ref={f.text_reference} correction={f.correction} />
)}
{/* Fallback: correction without text reference */}
{!f.text_reference && f.correction && (
<div className="mt-2">
<button
onClick={() => setExpandedCorrection(isExpanded ? null : f.code)}
className="text-xs text-purple-600 hover:text-purple-800 font-medium"
>
{isExpanded ? '▼ Korrekturvorschlag ausblenden' : '▶ Korrekturvorschlag anzeigen'}
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
{isExp ? 'Korrektur ausblenden' : 'Korrekturvorschlag'}
</button>
{isExpanded && (
{isExp && (
<div className="mt-2 bg-white border border-gray-200 rounded-lg p-3 relative">
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-sans">{f.correction}</pre>
<button
onClick={() => navigator.clipboard.writeText(f.correction)}
className="absolute top-2 right-2 text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded"
title="Kopieren"
>
<button onClick={() => navigator.clipboard.writeText(f.correction)}
className="absolute top-2 right-2 text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded">
Kopieren
</button>
</div>
@@ -185,6 +283,35 @@ export function ScanResult({ data }: { data: ScanData }) {
</div>
</div>
)}
{/* PDF Export Button */}
<div className="pt-4 border-t flex gap-3">
<button
onClick={async () => {
try {
const res = await fetch('/api/sdk/v1/agent/scans/pdf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: '', scan_type: 'scan', analysis_mode: 'post_launch', result: data }),
})
if (res.ok) {
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'compliance-report.pdf'
a.click()
URL.revokeObjectURL(url)
}
} catch (e) { console.error('PDF export failed:', e) }
}}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
>
<svg className="w-4 h-4" 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>
</div>
)
}
@@ -0,0 +1,108 @@
'use client'
import React, { useState } from 'react'
interface TextRef {
found: boolean
source_url: string
document_type: string
section_heading: string
section_number: string
parent_section: string
paragraph_index: number
original_text: string
issue: string
correction_type: string
correction_text: string
insert_after: string
}
const ISSUE_LABELS: Record<string, { label: string; color: string }> = {
missing: { label: 'Fehlt in der DSE', color: 'text-red-700 bg-red-50' },
incomplete: { label: 'Unvollstaendig', color: 'text-yellow-700 bg-yellow-50' },
incorrect: { label: 'Fehlerhaft', color: 'text-orange-700 bg-orange-50' },
}
const CORRECTION_LABELS: Record<string, string> = {
insert: 'Neuen Abschnitt einfuegen',
append: 'Am Ende des Absatzes ergaenzen',
replace: 'Absatz ersetzen',
}
export function TextReference({ ref, correction }: { ref: TextRef; correction?: string }) {
const [showCorrection, setShowCorrection] = useState(false)
const issue = ISSUE_LABELS[ref.issue] || null
const correctionText = correction || ref.correction_text
return (
<div className="mt-3 space-y-2 text-sm">
{/* Original Text Block */}
<div>
<p className="text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
<span>📄</span> Originaltextblock:
</p>
<div className={`rounded-lg p-3 border ${ref.found ? 'bg-gray-50 border-gray-200' : 'bg-red-50 border-red-200'}`}>
{ref.found ? (
<p className="text-gray-700 text-xs whitespace-pre-wrap">{ref.original_text || '(Textinhalt konnte nicht extrahiert werden)'}</p>
) : (
<p className="text-red-600 text-xs italic">Nicht vorhanden Eintrag fehlt in der {ref.document_type}.</p>
)}
</div>
</div>
{/* Position */}
<div>
<p className="text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
<span>📍</span> Position:
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2 text-xs text-blue-800">
{ref.found ? (
<>
<span className="font-semibold">{ref.section_heading || 'Abschnitt unbekannt'}</span>
{ref.section_number && <span className="text-blue-600 ml-1">(Nr. {ref.section_number})</span>}
{ref.parent_section && <span className="text-blue-500 ml-1">in: {ref.parent_section}</span>}
{ref.paragraph_index > 0 && <span className="text-blue-500 ml-1">| Absatz {ref.paragraph_index}</span>}
</>
) : ref.insert_after ? (
<span><strong>{CORRECTION_LABELS[ref.correction_type] || 'Einfuegen'}</strong> nach Abschnitt &quot;{ref.insert_after}&quot;</span>
) : (
<span>Neuen Abschnitt in der {ref.document_type} anlegen</span>
)}
{ref.source_url && (
<div className="text-blue-400 mt-1 truncate">in: {ref.source_url}</div>
)}
</div>
</div>
{/* Correction */}
{correctionText && (
<div>
<button
onClick={() => setShowCorrection(!showCorrection)}
className="text-xs text-purple-600 hover:text-purple-800 font-medium flex items-center gap-1"
>
<span>{showCorrection ? '▼' : '▶'}</span>
<span></span> Korrekturvorschlag {showCorrection ? 'ausblenden' : 'anzeigen'}
</button>
{showCorrection && (
<div className="mt-2 bg-white border border-purple-200 rounded-lg p-3 relative">
{issue && (
<span className={`text-[10px] px-2 py-0.5 rounded-full font-medium mb-2 inline-block ${issue.color}`}>
{CORRECTION_LABELS[ref.correction_type] || issue.label}
</span>
)}
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-sans mt-1">{correctionText}</pre>
<button
onClick={() => navigator.clipboard.writeText(correctionText)}
className="absolute top-2 right-2 text-xs bg-gray-100 hover:bg-gray-200 px-2 py-1 rounded transition-colors"
title="In Zwischenablage kopieren"
>
📋 Kopieren
</button>
</div>
)}
</div>
)}
</div>
)
}
+152 -104
View File
@@ -1,145 +1,193 @@
'use client'
import React, { useState } from 'react'
import { useAgentAnalysis } from './_hooks/useAgentAnalysis'
import { AnalysisResult } from './_components/AnalysisResult'
import { AnalysisHistory } from './_components/AnalysisHistory'
import { FollowUpQuestions } from './_components/FollowUpQuestions'
import { ScanResult } from './_components/ScanResult'
import { ComplianceCheckTab } from './_components/ComplianceCheckTab'
import { BannerCheckTab } from './_components/BannerCheckTab'
import { ComplianceFAQ } from './_components/ComplianceFAQ'
type AnalysisMode = 'pre_launch' | 'post_launch'
type AnalysisTab = 'quick' | 'scan'
const MODES: { id: AnalysisMode; label: string; desc: string; icon: string }[] = [
{ id: 'pre_launch', label: 'Internes Dokument', desc: 'Vor Veroeffentlichung pruefen', icon: '📋' },
{ id: 'post_launch', label: 'Live-Website', desc: 'Bereits online analysieren', icon: '🌐' },
]
type AnalysisTab = 'scan' | 'compliance-check' | 'banner-check'
const TABS: { id: AnalysisTab; label: string; desc: string }[] = [
{ id: 'quick', label: 'Schnellanalyse', desc: 'Einzelne Seite klassifizieren + bewerten' },
{ id: 'scan', label: 'Website-Scan', desc: 'Mehrere Seiten scannen + Dienstleister abgleichen' },
{ id: 'scan', label: 'Website-Scan', desc: 'Rechtliche Dokumente finden + Dienstleister erkennen' },
{ id: 'compliance-check', label: 'Compliance-Check', desc: 'Alle rechtlichen Dokumente zusammen pruefen' },
{ id: 'banner-check', label: 'Banner-Check', desc: 'Cookie-Banner auf DSGVO-Konformitaet testen' },
]
export default function AgentPage() {
const [url, setUrl] = useState('')
const [mode, setMode] = useState<AnalysisMode>('post_launch')
const [tab, setTab] = useState<AnalysisTab>('quick')
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'compliance-check')
const [scanLoading, setScanLoading] = useState(false)
const [scanError, setScanError] = useState<string | null>(null)
const [scanData, setScanData] = useState<any>(null)
const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis()
const [scanData, setScanData] = useState<any>(() => {
if (typeof window === 'undefined') return null
try { const s = localStorage.getItem('agent-scan-result'); return s ? JSON.parse(s) : null } catch { return null }
})
const [scanProgress, setScanProgress] = useState<string>('')
const [activeScanId, setActiveScanId] = useState<string>(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-id') || '' : '')
const [scanHistory, setScanHistory] = useState<{ url: string; date: string; findings: number; docs: number; resultKey: string }[]>(() => {
if (typeof window === 'undefined') return []
try { return JSON.parse(localStorage.getItem('agent-scan-history') || '[]') } catch { return [] }
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!url.trim()) return
React.useEffect(() => { localStorage.setItem('agent-scan-url', url) }, [url])
React.useEffect(() => { localStorage.setItem('agent-scan-tab', tab) }, [tab])
if (tab === 'quick') {
analyze(url.trim(), mode)
} else {
setScanLoading(true)
setScanError(null)
setScanData(null)
try {
const res = await fetch('/api/sdk/v1/agent/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.trim(), mode }),
})
if (!res.ok) throw new Error(`Scan fehlgeschlagen: ${res.status}`)
setScanData(await res.json())
} catch (e) {
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setScanLoading(false)
// Resume polling if scan was in progress
React.useEffect(() => {
if (!activeScanId || scanData?.services) return
let cancelled = false
setScanLoading(true)
setScanProgress('Scan laeuft noch...')
const poll = async () => {
while (!cancelled) {
await new Promise(r => setTimeout(r, 5000))
try {
const res = await fetch(`/api/sdk/v1/agent/scan?scan_id=${activeScanId}`)
if (!res.ok) continue
const data = await res.json()
if (data.progress) setScanProgress(data.progress)
if (data.status === 'completed' && data.result) {
setScanData(data.result); setScanProgress(''); setScanLoading(false)
localStorage.setItem('agent-scan-result', JSON.stringify(data.result))
localStorage.removeItem('agent-scan-id'); setActiveScanId('')
_addToHistory(data.result); return
}
if (data.status === 'failed' || data.status === 'not_found') {
if (data.status === 'failed') setScanError(data.error || 'Scan fehlgeschlagen')
setScanProgress(''); setScanLoading(false)
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); return
}
} catch {}
}
}
poll()
return () => { cancelled = true }
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const _addToHistory = (result: any) => {
const resultKey = `scan-result-${Date.now()}`
try { localStorage.setItem(resultKey, JSON.stringify(result)) } catch {}
const entry = { url: url || result.url || '', date: new Date().toISOString(), findings: result.findings?.length || 0, docs: result.discovered_documents?.length || 0, resultKey }
const updated = [entry, ...scanHistory].slice(0, 30)
setScanHistory(updated); localStorage.setItem('agent-scan-history', JSON.stringify(updated))
}
const isLoading = tab === 'quick' ? loading : scanLoading
const currentError = tab === 'quick' ? error : scanError
const handleScan = async (e: React.FormEvent) => {
e.preventDefault()
if (!url.trim()) return
setScanLoading(true); setScanError(null); setScanData(null); setScanProgress('Scan wird gestartet...')
try {
const startRes = await fetch('/api/sdk/v1/agent/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url.trim(), mode: 'post_launch' }) })
if (!startRes.ok) throw new Error(`Scan konnte nicht gestartet werden: ${startRes.status}`)
const { scan_id } = await startRes.json()
if (!scan_id) throw new Error('Keine Scan-ID erhalten')
setActiveScanId(scan_id); localStorage.setItem('agent-scan-id', scan_id)
let attempts = 0
while (attempts < 120) {
await new Promise(r => setTimeout(r, 5000))
const pollRes = await fetch(`/api/sdk/v1/agent/scan?scan_id=${scan_id}`)
if (!pollRes.ok) { attempts++; continue }
const pollData = await pollRes.json()
if (pollData.progress) setScanProgress(pollData.progress)
if (pollData.status === 'completed' && pollData.result) {
setScanData(pollData.result); setScanProgress('')
localStorage.setItem('agent-scan-result', JSON.stringify(pollData.result))
localStorage.removeItem('agent-scan-id'); setActiveScanId(''); _addToHistory(pollData.result); break
}
if (pollData.status === 'failed') throw new Error(pollData.error || 'Scan fehlgeschlagen')
attempts++
}
if (attempts >= 120) throw new Error('Scan-Timeout (10 Minuten)')
} catch (e) { setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler'); setScanProgress('') }
finally { setScanLoading(false) }
}
const navigateToCheck = (targetTab: AnalysisTab, checkUrl: string) => {
const keyMap: Record<string, string> = { 'doc-check': 'doc-check-prefill-url', 'banner-check': 'banner-check-url', 'impressum-check': 'impressum-check-url' }
if (keyMap[targetTab]) localStorage.setItem(keyMap[targetTab], checkUrl)
setTab(targetTab)
}
const discoveredDocs = scanData?.discovered_documents || []
const scannedUrl = scanData?.url || url
return (
<div className="space-y-6 max-w-4xl">
<div>
<h1 className="text-2xl font-bold text-gray-900">Compliance Agent</h1>
<p className="text-gray-500 mt-1">Analysiere Dokumente und Webseiten auf DSGVO-Konformitaet.</p>
<p className="text-gray-500 mt-1">Analysiere Webseiten und Dokumente auf DSGVO-Konformitaet.</p>
</div>
{/* Mode Selection */}
<div className="grid grid-cols-2 gap-3">
{MODES.map(m => (
<button key={m.id} onClick={() => setMode(m.id)}
className={`p-3 rounded-xl border-2 text-left transition-all ${
mode === m.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-gray-300'}`}>
<div className="flex items-center gap-3">
<span className="text-xl">{m.icon}</span>
<div>
<p className={`text-sm font-semibold ${mode === m.id ? 'text-purple-900' : 'text-gray-900'}`}>{m.label}</p>
<p className="text-xs text-gray-500">{m.desc}</p>
</div>
</div>
</button>
))}
</div>
{/* Tab Selection */}
<div className="flex border-b border-gray-200">
<div className="flex border-b border-gray-200 overflow-x-auto">
{TABS.map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
tab === t.id
? 'border-purple-500 text-purple-700'
: 'border-transparent text-gray-500 hover:text-gray-700'}`}>
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
tab === t.id ? 'border-purple-500 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}>
{t.label}
</button>
))}
</div>
{/* URL Input */}
<form onSubmit={handleSubmit} className="flex gap-3">
<input type="url" value={url} onChange={e => setUrl(e.target.value)}
placeholder={tab === 'scan' ? 'https://www.example.com/' : 'https://example.com/datenschutz'}
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm"
disabled={isLoading} required />
<button type="submit" disabled={isLoading || !url.trim()}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium">
{isLoading ? (
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>{tab === 'scan' ? 'Scanne...' : 'Analysiere...'}</>
) : tab === 'scan' ? 'Website scannen' : 'Analysieren'}
</button>
</form>
{/* Error */}
{currentError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{currentError}</div>
)}
{/* Quick Analysis Result */}
{tab === 'quick' && result && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm space-y-6">
<AnalysisResult result={result} />
{result.follow_up_questions.length > 0 && (
<div className="border-t pt-4">
<FollowUpQuestions questions={result.follow_up_questions} answers={result.follow_up_answers} onAnswer={answerFollowUp} />
{tab === 'scan' && (
<div className="space-y-4">
<div className="bg-indigo-50 border border-indigo-200 rounded-lg p-4">
<h3 className="text-sm font-semibold text-indigo-900">Website-Scan (Discovery)</h3>
<p className="text-xs text-indigo-700 mt-1">Findet alle rechtlichen Dokumente (DSI, AGB, Impressum, Cookie, Widerruf), erkennt eingesetzte Drittdienste und prueft ob sie in der DSE dokumentiert sind.</p>
</div>
<form onSubmit={handleScan} className="flex gap-3">
<input type="url" value={url} onChange={e => setUrl(e.target.value)} placeholder="https://www.example.com/"
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent text-sm" disabled={scanLoading} required />
<button type="submit" disabled={scanLoading || !url.trim()}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors flex items-center gap-2 text-sm font-medium whitespace-nowrap">
{scanLoading ? (<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>Scanne...</>) : 'Website scannen'}
</button>
</form>
{scanProgress && <div className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-sm text-purple-700 flex items-center gap-3"><svg className="animate-spin w-5 h-5 text-purple-500 shrink-0" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>{scanProgress}</div>}
{scanError && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">{scanError}</div>}
{scanData && (
<div className="bg-white border border-gray-200 rounded-xl p-4 shadow-sm">
<h4 className="text-sm font-semibold text-gray-800 mb-3">Jetzt pruefen</h4>
<div className="grid grid-cols-2 gap-2">
<button onClick={() => navigateToCheck('banner-check', scannedUrl)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
<div className="text-sm font-medium text-gray-900">Cookie-Banner pruefen</div>
<div className="text-xs text-gray-500 mt-0.5">3-Phasen Dark-Pattern-Analyse</div>
</button>
<button onClick={() => navigateToCheck('impressum-check', scannedUrl + '/impressum')} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
<div className="text-sm font-medium text-gray-900">Impressum pruefen</div>
<div className="text-xs text-gray-500 mt-0.5">§5 TMG Pflichtangaben</div>
</button>
{discoveredDocs.map((doc: any, i: number) => (
<button key={i} onClick={() => navigateToCheck('doc-check', doc.url)} className="p-3 rounded-lg border border-gray-200 hover:border-purple-300 hover:bg-purple-50 transition-all text-left">
<div className="text-sm font-medium text-gray-900 truncate">{doc.title || doc.url}</div>
<div className="text-xs text-gray-500 mt-0.5">{doc.doc_type?.toUpperCase()} · {doc.word_count || '?'} Woerter{doc.completeness_pct != null && ` · ${doc.completeness_pct}%`}</div>
</button>
))}
</div>
</div>
)}
{scanData?.services && <div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm"><ScanResult data={scanData} /></div>}
{scanHistory.length > 0 && (
<div className="border border-gray-200 rounded-xl p-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">Letzte Scans</h4>
<div className="space-y-2">
{scanHistory.map((h, i) => (
<button key={i} onClick={() => { setUrl(h.url); if (h.resultKey) { try { const s = localStorage.getItem(h.resultKey); if (s) { setScanData(JSON.parse(s)); return } } catch {} } }}
className="w-full flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all text-left">
<div className="min-w-0 flex-1"><div className="text-sm font-medium text-gray-900 truncate">{h.url}</div><div className="text-xs text-gray-500">{new Date(h.date).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</div></div>
<div className="flex items-center gap-3 shrink-0 ml-3">{h.docs > 0 && <span className="text-xs text-purple-600">{h.docs} Dok.</span>}<span className={`text-xs font-medium ${h.findings > 0 ? 'text-red-600' : 'text-green-600'}`}>{h.findings} Findings</span></div>
</button>
))}
</div>
</div>
)}
</div>
)}
{/* Scan Result */}
{tab === 'scan' && scanData && (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<ScanResult data={scanData} />
</div>
)}
{tab === 'compliance-check' && <ComplianceCheckTab />}
{tab === 'banner-check' && <BannerCheckTab />}
{/* History (quick only) */}
{tab === 'quick' && (
<AnalysisHistory history={history} onSelect={r => { setUrl(r.url); analyze(r.url, mode) }} />
)}
<ComplianceFAQ />
</div>
)
}
@@ -0,0 +1,46 @@
'use client'
import { useState, useEffect } from 'react'
export interface AuditEntry {
id: string
entity_type: string
entity_id: string
entity_name: string
action: string
field_changed: string | null
old_value: string | null
new_value: string | null
change_summary: string | null
performed_by: string
performed_at: string
}
export function useAuditTimeline() {
const [entries, setEntries] = useState<AuditEntry[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<string>('all')
useEffect(() => {
loadEntries()
}, [filter]) // eslint-disable-line react-hooks/exhaustive-deps
async function loadEntries() {
setLoading(true)
try {
const params = new URLSearchParams({ limit: '100' })
if (filter !== 'all') params.set('entity_type', filter)
const res = await fetch(`/api/sdk/v1/compliance/audit-trail?${params}`)
if (res.ok) {
const json = await res.json()
setEntries(json.entries || json.audit_trail || json || [])
}
} catch (err) {
console.error('Failed to load audit trail:', err)
} finally {
setLoading(false)
}
}
return { entries, loading, filter, setFilter }
}
@@ -0,0 +1,117 @@
'use client'
import { useAuditTimeline, type AuditEntry } from './_hooks/useAuditTimeline'
const ENTITY_LABELS: Record<string, string> = {
evidence: 'Nachweis', control: 'Control', document: 'Dokument',
dsfa: 'DSFA', vvt: 'VVT', tom: 'TOM', policy: 'Richtlinie',
dsms_archive: 'DSMS-Archiv', risk: 'Risiko',
}
const ACTION_COLORS: Record<string, string> = {
create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500',
approve: 'bg-purple-500', archive: 'bg-emerald-500', review: 'bg-yellow-500',
sign: 'bg-indigo-500', reject: 'bg-red-400',
}
const FILTER_OPTIONS = ['all', 'evidence', 'dsms_archive', 'control', 'document', 'dsfa', 'vvt', 'tom']
export default function AuditTimelinePage() {
const { entries, loading, filter, setFilter } = useAuditTimeline()
return (
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Audit Timeline</h1>
<p className="text-sm text-gray-500 mt-1">Chronologische Compliance-Historie mit DSMS-Nachweisen</p>
</div>
{/* Filter */}
<div className="flex gap-2 flex-wrap">
{FILTER_OPTIONS.map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filter === f
? 'bg-purple-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{f === 'all' ? 'Alle' : ENTITY_LABELS[f] || f}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
</div>
) : entries.length === 0 ? (
<div className="text-center py-16 text-gray-500">
Keine Eintraege gefunden. Compliance-Aktionen werden automatisch protokolliert.
</div>
) : (
<div className="relative">
{/* Timeline line */}
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
<div className="space-y-4">
{entries.map((entry) => (
<TimelineEntry key={entry.id} entry={entry} />
))}
</div>
</div>
)}
</div>
)
}
function TimelineEntry({ entry }: { entry: AuditEntry }) {
const dotColor = ACTION_COLORS[entry.action] || 'bg-gray-400'
const isCID = entry.field_changed === 'dsms_cid' || entry.action === 'archive'
const date = new Date(entry.performed_at)
return (
<div className="relative flex gap-4 pl-3">
{/* Dot */}
<div className={`relative z-10 w-3 h-3 rounded-full mt-1.5 flex-shrink-0 ring-4 ring-white dark:ring-gray-900 ${dotColor}`} />
{/* Content */}
<div className="flex-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 min-w-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-900 dark:text-white">{entry.entity_name}</span>
<span className="px-2 py-0.5 rounded text-[10px] font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">
{ENTITY_LABELS[entry.entity_type] || entry.entity_type}
</span>
<span className={`px-2 py-0.5 rounded text-[10px] font-medium text-white ${dotColor}`}>
{entry.action}
</span>
</div>
{entry.change_summary && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">{entry.change_summary}</p>
)}
{isCID && entry.new_value && (
<div className="mt-2 flex items-center gap-2">
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<code className="text-[10px] bg-emerald-50 text-emerald-700 px-2 py-0.5 rounded font-mono dark:bg-emerald-900/30 dark:text-emerald-300">
{entry.new_value.length > 20 ? entry.new_value.slice(0, 8) + '...' + entry.new_value.slice(-6) : entry.new_value}
</code>
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
</div>
)}
</div>
<div className="text-right flex-shrink-0">
<div className="text-xs text-gray-400">{date.toLocaleDateString('de-DE')}</div>
<div className="text-[10px] text-gray-300">{date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</div>
<div className="text-[10px] text-gray-300 mt-0.5">{entry.performed_by}</div>
</div>
</div>
</div>
</div>
)
}
+301
View File
@@ -0,0 +1,301 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
/**
* CMP Dashboard Consent Management Platform overview.
*
* Aggregates data from: Banner API, Einwilligungen, DSR, Vendors.
* State-of-the-art layout inspired by OneTrust/Cookiebot dashboards
* but with EWR-Only as unique differentiator.
*/
// Use Next.js API proxy to avoid SSL cert issues
const BANNER_API = '/api/sdk/v1/banner'
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const HEADERS = { 'x-tenant-id': TENANT_ID }
interface BannerStats { total_consents: number; category_acceptance: Record<string, { count: number; rate: number }> }
interface ConsentStats { total_consents: number; active_consents: number; revoked_consents: number; unique_users: number; conversion_rate: number }
interface DSRStats { total: number; by_status: Record<string, number>; by_type: Record<string, number>; overdue: number; due_this_week: number; average_processing_days: number; completed_this_month: number }
const MODULES = [
{ href: '/sdk/cookie-banner', label: 'Cookie-Banner', desc: 'Banner konfigurieren und Code exportieren', icon: 'shield', color: 'purple' },
{ href: '/sdk/cookie-banner/preview', label: 'Live-Vorschau', desc: 'Banner auf simulierter Website testen', icon: 'eye', color: 'blue' },
{ href: '/sdk/einwilligungen', label: 'Consent-Records', desc: 'Einwilligungen einsehen und verwalten', icon: 'clipboard', color: 'green' },
{ href: '/sdk/consent-management', label: 'Consent-Verwaltung', desc: 'Dokument-Lifecycle und DSGVO-Prozesse', icon: 'folder', color: 'indigo' },
{ href: '/sdk/vendor-compliance', label: 'Vendor-Compliance', desc: 'Dienstleister und Auftragsverarbeitung', icon: 'users', color: 'amber' },
{ href: '/sdk/dsr', label: 'DSR Portal', desc: 'Betroffenenrechte Art. 15-21 DSGVO', icon: 'user', color: 'rose' },
{ href: '/sdk/loeschfristen', label: 'Loeschfristen', desc: 'Aufbewahrungsrichtlinien verwalten', icon: 'clock', color: 'teal' },
{ href: '/sdk/email-templates', label: 'E-Mail-Templates', desc: 'Benachrichtigungsvorlagen', icon: 'mail', color: 'slate' },
]
const ICON_MAP: Record<string, JSX.Element> = {
shield: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />,
eye: <><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></>,
clipboard: <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-6 9l2 2 4-4" />,
folder: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />,
users: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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 0z" />,
user: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />,
clock: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />,
mail: <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />,
}
const COLOR_MAP: Record<string, string> = {
purple: 'bg-purple-100 text-purple-600', blue: 'bg-blue-100 text-blue-600',
green: 'bg-green-100 text-green-600', indigo: 'bg-indigo-100 text-indigo-600',
amber: 'bg-amber-100 text-amber-600', rose: 'bg-rose-100 text-rose-600',
teal: 'bg-teal-100 text-teal-600', slate: 'bg-slate-100 text-slate-600',
}
export default function CMPDashboardPage() {
const [bannerStats, setBannerStats] = useState<BannerStats | null>(null)
const [consentStats, setConsentStats] = useState<ConsentStats | null>(null)
const [dsrStats, setDSRStats] = useState<DSRStats | null>(null)
const [sites, setSites] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
async function load() {
const fb = (path: string) => fetch(`${BANNER_API}/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
const fa = (path: string) => fetch(`/api/sdk/v1/compliance/${path}`, { headers: HEADERS }).then(r => r.ok ? r.json() : null).catch(() => null)
const [banner, consent, dsr, siteList] = await Promise.all([
fb('admin/stats/preview-test-site'),
fa('einwilligungen/consents/stats'),
fa('dsr/stats'),
fb('admin/sites'),
])
setBannerStats(banner)
setConsentStats(consent)
setDSRStats(dsr)
setSites(siteList || [])
setLoading(false)
}
load()
}, [])
const totalConsents = (bannerStats?.total_consents || 0) + (consentStats?.total_consents || 0)
const dsrOpen = dsrStats ? (dsrStats.by_status?.intake || 0) + (dsrStats.by_status?.processing || 0) + (dsrStats.by_status?.identity_verification || 0) : 0
const dsrOverdue = dsrStats?.overdue || 0
const catAcceptance = bannerStats?.category_acceptance || {}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Consent Management Platform</h1>
<p className="text-gray-500 mt-1">Ueberblick ueber Einwilligungen, Betroffenenrechte und Vendor-Compliance</p>
</div>
<Link href="/sdk/cookie-banner/preview"
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm font-medium hover:bg-purple-700 transition-colors">
Banner testen
</Link>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<KPICard label="Consents gesamt" value={loading ? '...' : totalConsents} icon="shield" trend={null} />
<KPICard label="Aktive Einwilligungen" value={loading ? '...' : consentStats?.active_consents ?? 0} icon="check" trend={consentStats?.conversion_rate ? `${consentStats.conversion_rate.toFixed(0)}% Rate` : null} />
<KPICard label="Offene DSR-Anfragen" value={loading ? '...' : dsrOpen} icon="user" trend={dsrOverdue > 0 ? `${dsrOverdue} ueberfaellig` : null} trendColor={dsrOverdue > 0 ? 'red' : 'green'} />
<KPICard label="Konfigurierte Sites" value={loading ? '...' : sites.length} icon="globe" trend={null} />
</div>
{/* Category Acceptance + DSR Breakdown */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Cookie Category Acceptance */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Cookie-Kategorie Akzeptanz</h3>
{Object.keys(catAcceptance).length > 0 ? (
<div className="space-y-3">
{Object.entries(catAcceptance).map(([cat, data]) => (
<div key={cat} className="flex items-center gap-4">
<span className="text-sm text-gray-600 w-24 capitalize">{cat}</span>
<div className="flex-1 h-6 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
cat === 'necessary' ? 'bg-gray-400' : cat === 'marketing' ? 'bg-rose-500' : cat === 'statistics' ? 'bg-blue-500' : 'bg-green-500'
}`}
style={{ width: `${data.rate}%` }}
/>
</div>
<span className="text-sm font-medium text-gray-700 w-16 text-right">{data.rate}%</span>
<span className="text-xs text-gray-400 w-12 text-right">{data.count}x</span>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-400">
<p className="text-sm">Noch keine Consent-Daten vorhanden</p>
<Link href="/sdk/cookie-banner/preview" className="text-purple-600 text-sm underline mt-2 inline-block">
Jetzt Banner testen
</Link>
</div>
)}
</div>
{/* DSR Breakdown */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-900">Betroffenenrechte (DSR)</h3>
<Link href="/sdk/dsr" className="text-xs text-purple-600 hover:underline">Alle anzeigen</Link>
</div>
{dsrStats && dsrStats.total > 0 ? (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<MiniStat label="Gesamt" value={dsrStats.total} />
<MiniStat label="Abgeschlossen" value={dsrStats.by_status?.completed || 0} color="green" />
<MiniStat label="Ueberfaellig" value={dsrOverdue} color={dsrOverdue > 0 ? 'red' : 'gray'} />
</div>
<div className="border-t border-gray-100 pt-3">
<div className="text-xs text-gray-500 mb-2">Nach Typ</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(dsrStats.by_type || {}).filter(([, v]) => v > 0).map(([type, count]) => (
<div key={type} className="flex items-center justify-between text-sm">
<span className="text-gray-600">{DSR_TYPE_LABELS[type] || type}</span>
<span className="font-medium text-gray-800">{count}</span>
</div>
))}
</div>
</div>
{dsrStats.average_processing_days > 0 && (
<div className="border-t border-gray-100 pt-3 flex items-center justify-between text-sm">
<span className="text-gray-500">Durchschnittl. Bearbeitungszeit</span>
<span className="font-medium text-gray-800">{dsrStats.average_processing_days.toFixed(1)} Tage</span>
</div>
)}
</div>
) : (
<div className="text-center py-8 text-gray-400 text-sm">
Keine DSR-Anfragen vorhanden
</div>
)}
</div>
</div>
{/* Banner-Bedarf Hinweis (TTDSG § 25) */}
{bannerStats && Object.keys(bannerStats.category_acceptance).length === 0 && sites.length === 0 && (
<div className="bg-green-50 border border-green-200 rounded-xl p-5 flex items-start gap-4">
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center flex-shrink-0">
<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="M5 13l4 4L19 7" /></svg>
</div>
<div>
<h3 className="font-semibold text-green-800">Kein Cookie-Banner erforderlich</h3>
<p className="text-sm text-green-700 mt-1">
Es wurden keine Cookies, Tracker oder Analytics-Dienste erkannt. Gemaess TTDSG § 25 ist kein
Cookie-Banner erforderlich, da keine Informationen auf dem Endgeraet gespeichert werden.
</p>
<p className="text-xs text-green-600 mt-2">
<strong>Weiterhin Pflicht:</strong> Impressum (DDG § 5) und Datenschutzerklaerung (DSGVO Art. 13)
</p>
</div>
</div>
)}
{/* Banner-Warnung wenn Tracker ohne Banner */}
{bannerStats && Object.keys(bannerStats.category_acceptance).length > 0 && sites.length === 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-5 flex items-start gap-4">
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center flex-shrink-0">
<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>
<h3 className="font-semibold text-red-800">Cookie-Banner fehlt!</h3>
<p className="text-sm text-red-700 mt-1">
Es wurden Tracking-Dienste erkannt, aber kein Cookie-Banner ist konfiguriert.
Gemaess TTDSG § 25 ist eine Einwilligung erforderlich.
</p>
<Link href="/sdk/cookie-banner" className="inline-block mt-2 text-sm text-red-700 font-medium underline">
Jetzt Cookie-Banner einrichten
</Link>
</div>
</div>
)}
{/* Compliance Status */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-1">Compliance-Status</h3>
<p className="text-xs text-gray-500 mb-4">Pruefung der wichtigsten DSGVO-Anforderungen</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<ComplianceCheck label="Cookie-Banner konfiguriert" ok={sites.length > 0} href="/sdk/cookie-banner" />
<ComplianceCheck label="Datenschutzerklaerung erstellt" ok={false} href="/sdk/einwilligungen/privacy-policy" />
<ComplianceCheck label="Impressum verlinkt" ok={false} href="/sdk/document-generator" />
<ComplianceCheck label="Consent-Nachweis (Art. 7)" ok={totalConsents > 0} href="/sdk/einwilligungen" />
<ComplianceCheck label="DSR-Prozess eingerichtet" ok={dsrStats?.total !== undefined} href="/sdk/dsr" />
<ComplianceCheck label="Loeschfristen definiert" ok={false} href="/sdk/loeschfristen" />
<ComplianceCheck label="Vendor-AVV vorhanden" ok={false} href="/sdk/vendor-compliance" />
<ComplianceCheck label="E-Mail-Templates aktiv" ok={false} href="/sdk/email-templates" />
<ComplianceCheck label="EWR-Only Modus verfuegbar" ok={true} href="/sdk/cookie-banner" />
</div>
</div>
{/* Module Grid */}
<div>
<h3 className="font-semibold text-gray-900 mb-3">CMP Module</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{MODULES.map(m => (
<Link key={m.href} href={m.href}
className="group bg-white border border-gray-200 rounded-xl p-4 hover:border-purple-300 hover:shadow-md transition-all">
<div className={`w-10 h-10 rounded-lg ${COLOR_MAP[m.color]} flex items-center justify-center mb-3`}>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{ICON_MAP[m.icon]}
</svg>
</div>
<div className="font-medium text-gray-900 group-hover:text-purple-700 text-sm">{m.label}</div>
<div className="text-xs text-gray-500 mt-0.5">{m.desc}</div>
</Link>
))}
</div>
</div>
</div>
)
}
const DSR_TYPE_LABELS: Record<string, string> = {
access: 'Auskunft (Art. 15)', rectification: 'Berichtigung (Art. 16)',
erasure: 'Loeschung (Art. 17)', restriction: 'Einschraenkung (Art. 18)',
portability: 'Portabilitaet (Art. 20)', objection: 'Widerspruch (Art. 21)',
}
function KPICard({ label, value, icon, trend, trendColor }: {
label: string; value: number | string; icon: string; trend: string | null; trendColor?: string
}) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500">{label}</div>
<div className="text-2xl font-bold text-gray-900 mt-1">{value}</div>
{trend && (
<div className={`text-xs mt-1 ${trendColor === 'red' ? 'text-red-600' : trendColor === 'green' ? 'text-green-600' : 'text-gray-500'}`}>
{trend}
</div>
)}
</div>
)
}
function MiniStat({ label, value, color }: { label: string; value: number; color?: string }) {
const c = color === 'red' ? 'text-red-600' : color === 'green' ? 'text-green-600' : 'text-gray-900'
return (
<div className="text-center">
<div className={`text-xl font-bold ${c}`}>{value}</div>
<div className="text-xs text-gray-500">{label}</div>
</div>
)
}
function ComplianceCheck({ label, ok, href }: { label: string; ok: boolean; href: string }) {
return (
<Link href={href} className="flex items-center gap-3 p-3 rounded-lg border border-gray-100 hover:border-purple-200 hover:bg-purple-50/30 transition-all">
{ok ? (
<svg className="w-5 h-5 text-green-500 shrink-0" 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>
) : (
<svg className="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<span className="text-sm text-gray-700">{label}</span>
</Link>
)
}
@@ -0,0 +1,49 @@
'use client'
import { COMPANY_PROFILE_PRESETS, type CompanyProfilePreset } from '@/lib/sdk/company-profile-presets'
interface PresetSelectorProps {
onSelect: (preset: CompanyProfilePreset) => void
onSkip: () => void
}
export function PresetSelector({ onSelect, onSkip }: PresetSelectorProps) {
return (
<div className="space-y-6">
<div className="text-center">
<h2 className="text-xl font-bold text-gray-900">Welcher Unternehmenstyp passt zu Ihnen?</h2>
<p className="text-sm text-gray-500 mt-2">
Waehlen Sie eine Vorlage fuer Ihre Branche alle Felder werden vorbefuellt
und Sie koennen anschliessend anpassen.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{COMPANY_PROFILE_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => onSelect(preset)}
className="flex flex-col items-center gap-2 p-4 bg-white border border-gray-200 rounded-xl hover:border-purple-400 hover:shadow-md transition-all text-center group"
>
<span className="text-3xl">{preset.icon}</span>
<span className="text-sm font-medium text-gray-900 group-hover:text-purple-700">
{preset.label}
</span>
<span className="text-xs text-gray-500 leading-tight">
{preset.description}
</span>
</button>
))}
</div>
<div className="text-center">
<button
onClick={onSkip}
className="text-sm text-gray-400 hover:text-gray-600 underline"
>
Manuell ausfuellen (ohne Vorlage)
</button>
</div>
</div>
)
}
@@ -78,6 +78,14 @@ export default function ComplianceScopePage() {
const [supervisoryAuthorities, setSupervisoryAuthorities] = useState<SupervisoryAuthorityInfo[]>([])
const [regulationAssessmentLoading, setRegulationAssessmentLoading] = useState(false)
// Enabled compliance modules (derived from applicable regulations)
const [enabledModules, setEnabledModules] = useState<string[]>([])
// Auto-enable all applicable regulations when they load
const handleToggleModule = (moduleId: string, enabled: boolean) => {
setEnabledModules(prev => enabled ? [...prev, moduleId] : prev.filter(id => id !== moduleId))
}
// Sync from SDK context when it becomes available (handles async loading).
// The SDK context loads state from server/localStorage asynchronously, so
// sdkState.complianceScope may arrive AFTER this page has already mounted.
@@ -159,6 +167,10 @@ export default function ComplianceScopePage() {
// Set applicable regulations from response
const regs: ApplicableRegulation[] = data.overview?.applicable_regulations || data.applicable_regulations || []
setApplicableRegulations(regs)
// Auto-enable all applicable regulations as modules
if (enabledModules.length === 0) {
setEnabledModules(regs.map(r => r.id))
}
// Derive supervisory authorities
const regIds = regs.map(r => r.id)
@@ -375,6 +387,8 @@ export default function ComplianceScopePage() {
supervisoryAuthorities={supervisoryAuthorities}
regulationAssessmentLoading={regulationAssessmentLoading}
onGoToObligations={() => { window.location.href = '/sdk/obligations' }}
enabledModules={enabledModules}
onToggleModule={handleToggleModule}
/>
)}
@@ -141,16 +141,24 @@ export default function ConsentManagementPage() {
)}
{activeTab === 'emails' && (
<EmailsTab
apiEmailTemplates={apiEmailTemplates}
templatesLoading={templatesLoading}
savingTemplateId={savingTemplateId}
savedTemplates={savedTemplates}
setShowCreateTemplateModal={setShowCreateTemplateModal}
saveApiEmailTemplate={saveApiEmailTemplate}
setPreviewTemplate={setPreviewTemplate}
setEditingTemplate={setEditingTemplate}
/>
<div className="bg-purple-50 border border-purple-200 rounded-xl p-8 text-center">
<div className="w-14 h-14 mx-auto mb-4 bg-purple-100 rounded-xl flex items-center justify-center">
<svg className="w-7 h-7 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
</svg>
</div>
<h3 className="font-semibold text-gray-900 mb-2">E-Mail-Templates wurden zentralisiert</h3>
<p className="text-sm text-gray-600 mb-4">
Alle E-Mail-Vorlagen (DSR, Consent, Breach, Training, etc.) werden jetzt zentral
im E-Mail-Template-Modul verwaltet mit Versionierung, Freigabe-Workflow und Audit-Log.
</p>
<button
onClick={() => router.push('/sdk/email-templates')}
className="px-6 py-2.5 bg-purple-600 text-white text-sm font-medium rounded-lg hover:bg-purple-700 transition-colors"
>
Zu E-Mail-Templates
</button>
</div>
)}
{activeTab === 'gdpr' && (
@@ -212,14 +212,14 @@ export function ControlDetail({
</section>
) : null}
{ctrl.requirements.length > 0 && (
{Array.isArray(ctrl.requirements) && ctrl.requirements.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Anforderungen</h3>
<ol className="list-decimal list-inside space-y-1">{ctrl.requirements.map((r, i) => <li key={i} className="text-sm text-gray-700">{r}</li>)}</ol>
</section>
)}
{ctrl.test_procedure.length > 0 && (
{Array.isArray(ctrl.test_procedure) && ctrl.test_procedure.length > 0 && (
<section>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Pruefverfahren</h3>
<ol className="list-decimal list-inside space-y-1">{ctrl.test_procedure.map((s, i) => <li key={i} className="text-sm text-gray-700">{s}</li>)}</ol>
@@ -18,7 +18,8 @@ export interface ControlsMeta {
const PAGE_SIZE = 50
export function useControlLibraryState() {
export function useControlLibraryState(backendUrlOverride?: string) {
const backendUrl = backendUrlOverride || BACKEND_URL
const [frameworks, setFrameworks] = useState<Framework[]>([])
const [controls, setControls] = useState<CanonicalControl[]>([])
const [totalCount, setTotalCount] = useState(0)
@@ -100,7 +101,7 @@ export function useControlLibraryState() {
const loadFrameworks = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=frameworks`)
const res = await fetch(`${backendUrl}?endpoint=frameworks`)
if (res.ok) setFrameworks(await res.json())
} catch { /* ignore */ }
}, [])
@@ -111,7 +112,7 @@ export function useControlLibraryState() {
metaAbortRef.current = controller
try {
const qs = buildParams()
const res = await fetch(`${BACKEND_URL}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
const res = await fetch(`${backendUrl}?endpoint=controls-meta${qs ? `&${qs}` : ''}`, { signal: controller.signal })
if (res.ok && !controller.signal.aborted) setMeta(await res.json())
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return
@@ -130,8 +131,8 @@ export function useControlLibraryState() {
const qs = buildParams({ sort: sortField, order: sortOrder, limit: String(PAGE_SIZE), offset: String(offset) })
const countQs = buildParams()
const [ctrlRes, countRes] = await Promise.all([
fetch(`${BACKEND_URL}?endpoint=controls&${qs}`, { signal: controller.signal }),
fetch(`${BACKEND_URL}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
fetch(`${backendUrl}?endpoint=controls&${qs}`, { signal: controller.signal }),
fetch(`${backendUrl}?endpoint=controls-count&${countQs}`, { signal: controller.signal }),
])
if (!controller.signal.aborted) {
if (ctrlRes.ok) setControls(await ctrlRes.json())
@@ -147,7 +148,7 @@ export function useControlLibraryState() {
const loadReviewCount = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=controls-count&release_state=needs_review`)
const res = await fetch(`${backendUrl}?endpoint=controls-count&release_state=needs_review`)
if (res.ok) { const data = await res.json(); setReviewCount(data.total || 0) }
} catch { /* ignore */ }
}, [])
@@ -165,14 +166,14 @@ export function useControlLibraryState() {
const loadProcessedStats = async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=processed-stats`)
const res = await fetch(`${backendUrl}?endpoint=processed-stats`)
if (res.ok) { const data = await res.json(); setProcessedStats(data.stats || []) }
} catch { /* ignore */ }
}
const enterReviewMode = async () => {
try {
const res = await fetch(`${BACKEND_URL}?endpoint=controls&release_state=needs_review&limit=1000`)
const res = await fetch(`${backendUrl}?endpoint=controls&release_state=needs_review&limit=1000`)
if (res.ok) {
const items: CanonicalControl[] = await res.json()
if (items.length > 0) {
@@ -62,6 +62,14 @@ export default function ControlLibraryPage() {
initial={{
...EMPTY_CONTROL,
...state.selectedControl,
scope: {
platforms: state.selectedControl.scope?.platforms ?? [],
components: state.selectedControl.scope?.components ?? [],
data_classes: state.selectedControl.scope?.data_classes ?? [],
},
target_audience: Array.isArray(state.selectedControl.target_audience)
? state.selectedControl.target_audience.join(', ')
: state.selectedControl.target_audience,
risk_score: state.selectedControl.risk_score,
implementation_effort: state.selectedControl.implementation_effort,
open_anchors: state.selectedControl.open_anchors.length > 0
@@ -69,7 +77,9 @@ export default function ControlLibraryPage() {
: [{ framework: '', ref: '', url: '' }],
requirements: state.selectedControl.requirements.length > 0 ? state.selectedControl.requirements : [''],
test_procedure: state.selectedControl.test_procedure.length > 0 ? state.selectedControl.test_procedure : [''],
evidence: state.selectedControl.evidence.length > 0 ? state.selectedControl.evidence : [{ type: '', description: '' }],
evidence: state.selectedControl.evidence.length > 0
? state.selectedControl.evidence.map(e => typeof e === 'string' ? { type: '', description: e } : e)
: [{ type: '', description: '' }],
}}
onSave={handleUpdate}
onCancel={() => state.setMode('detail')}
@@ -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>
)
}
@@ -0,0 +1,191 @@
'use client'
import { useState, useEffect } from 'react'
interface TimeSeriesPoint {
period: string
given: number
updated: number
withdrawn: number
total: number
opt_in_rate: number
}
interface CategoryStats {
[key: string]: { count: number; total: number; rate: number }
}
interface DeviceStats {
desktop: number
mobile: number
tablet: number
unknown: number
}
interface OverviewStats {
period_days: number
total_interactions: number
consents_given: number
consents_updated: number
consents_withdrawn: number
opt_in_rate: number
}
const PERIODS = [
{ value: 7, label: '7 Tage' },
{ value: 30, label: '30 Tage' },
{ value: 90, label: '90 Tage' },
]
const CAT_COLORS: Record<string, string> = {
necessary: '#22c55e',
statistics: '#eab308',
marketing: '#ef4444',
functional: '#3b82f6',
preferences: '#8b5cf6',
}
export function AnalyticsDashboard({ siteId }: { siteId?: string }) {
const [days, setDays] = useState(30)
const [overview, setOverview] = useState<OverviewStats | null>(null)
const [timeSeries, setTimeSeries] = useState<TimeSeriesPoint[]>([])
const [categories, setCategories] = useState<CategoryStats>({})
const [devices, setDevices] = useState<DeviceStats>({ desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
const [loading, setLoading] = useState(true)
const sid = siteId || 'preview-test-site'
useEffect(() => {
setLoading(true)
const base = `/api/sdk/v1/compliance/banner/analytics/${sid}`
Promise.all([
fetch(`${base}/overview?days=${days}`).then(r => r.ok ? r.json() : null),
fetch(`${base}/time-series?days=${days}&period=daily`).then(r => r.ok ? r.json() : []),
fetch(`${base}/categories?days=${days}`).then(r => r.ok ? r.json() : {}),
fetch(`${base}/devices?days=${days}`).then(r => r.ok ? r.json() : {}),
]).then(([o, ts, cats, devs]) => {
setOverview(o)
setTimeSeries(ts || [])
setCategories(cats || {})
setDevices(devs || { desktop: 0, mobile: 0, tablet: 0, unknown: 0 })
}).catch(() => {}).finally(() => setLoading(false))
}, [sid, days])
const deviceTotal = devices.desktop + devices.mobile + devices.tablet + devices.unknown
if (loading) return <div className="text-center py-12 text-gray-400">Lade Analytik...</div>
return (
<div className="space-y-6">
{/* Period Selector */}
<div className="flex items-center gap-2">
{PERIODS.map(p => (
<button key={p.value} onClick={() => setDays(p.value)}
className={`px-3 py-1.5 text-xs rounded-full border transition-colors ${
days === p.value ? 'bg-purple-100 border-purple-300 text-purple-700' : 'bg-white border-gray-200 text-gray-600 hover:border-gray-300'
}`}>
{p.label}
</button>
))}
</div>
{/* Overview KPIs */}
{overview && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500">Opt-In-Rate</div>
<div className="text-2xl font-bold text-green-600">{overview.opt_in_rate}%</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500">Einwilligungen</div>
<div className="text-2xl font-bold text-gray-900">{overview.consents_given}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500">Aktualisiert</div>
<div className="text-2xl font-bold text-blue-600">{overview.consents_updated}</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-xs text-gray-500">Widerrufen</div>
<div className="text-2xl font-bold text-red-600">{overview.consents_withdrawn}</div>
</div>
</div>
)}
{/* Time Series (simple bar visualization) */}
{timeSeries.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Opt-In-Rate im Zeitverlauf</h3>
<div className="flex items-end gap-1 h-32">
{timeSeries.map((pt, i) => {
const height = Math.max(pt.opt_in_rate, 2)
const date = new Date(pt.period)
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1 group relative">
<div className="w-full bg-purple-500 rounded-t transition-all hover:bg-purple-600"
style={{ height: `${height}%` }}
title={`${date.toLocaleDateString('de-DE')}: ${pt.opt_in_rate}% (${pt.total} Interaktionen)`}
/>
{i % Math.max(1, Math.floor(timeSeries.length / 6)) === 0 && (
<span className="text-[8px] text-gray-400">{date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })}</span>
)}
</div>
)
})}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Category Acceptance */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Akzeptanz nach Kategorie</h3>
<div className="space-y-3">
{Object.entries(categories).map(([cat, stats]) => (
<div key={cat}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-700 capitalize">{cat}</span>
<span className="font-medium text-gray-900">{stats.rate}%</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all" style={{ width: `${stats.rate}%`, backgroundColor: CAT_COLORS[cat] || '#9ca3af' }} />
</div>
</div>
))}
{Object.keys(categories).length === 0 && (
<p className="text-xs text-gray-400">Noch keine Daten vorhanden</p>
)}
</div>
</div>
{/* Device Breakdown */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="font-semibold text-gray-900 mb-4">Geraete-Verteilung</h3>
<div className="space-y-3">
{[
{ key: 'desktop', label: 'Desktop', color: 'bg-blue-500' },
{ key: 'mobile', label: 'Mobile', color: 'bg-green-500' },
{ key: 'tablet', label: 'Tablet', color: 'bg-purple-500' },
].map(d => {
const count = devices[d.key as keyof DeviceStats]
const pct = deviceTotal > 0 ? Math.round(count / deviceTotal * 100) : 0
return (
<div key={d.key}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-gray-700">{d.label}</span>
<span className="font-medium text-gray-900">{pct}% ({count})</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${d.color}`} style={{ width: `${pct}%` }} />
</div>
</div>
)
})}
{deviceTotal === 0 && (
<p className="text-xs text-gray-400">Noch keine Geraetedaten vorhanden</p>
)}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,103 @@
'use client'
import { useState, useEffect } from 'react'
interface Vendor {
vendor_name: string
vendor_url: string | null
category_key: string
description_de: string | null
cookie_names: string[]
retention_days: number | null
}
const CAT_LABELS: Record<string, string> = {
necessary: 'Notwendig',
functional: 'Funktional',
statistics: 'Statistik',
marketing: 'Marketing',
}
function generateHTML(vendors: Vendor[]): string {
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
const key = v.category_key || 'other'
if (!acc[key]) acc[key] = []
acc[key].push(v)
return acc
}, {})
let html = `<div style="font-family:system-ui,sans-serif;font-size:14px;color:#1f2937;">\n`
html += `<h3 style="margin:0 0 12px;font-size:16px;">Eingesetzte Dienste und Cookies</h3>\n`
for (const [catKey, catVendors] of Object.entries(grouped)) {
const label = CAT_LABELS[catKey] || catKey
html += `<h4 style="margin:16px 0 8px;font-size:14px;color:#6b21a8;">${label}</h4>\n`
html += `<table style="width:100%;border-collapse:collapse;margin-bottom:12px;font-size:13px;">\n`
html += `<tr style="background:#f9fafb;"><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Anbieter</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Zweck</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Cookies</th><th style="text-align:left;padding:6px 8px;border:1px solid #e5e7eb;">Speicherdauer</th></tr>\n`
for (const v of catVendors) {
const name = v.vendor_url
? `<a href="${v.vendor_url}" target="_blank" rel="noopener">${v.vendor_name}</a>`
: v.vendor_name
const cookies = v.cookie_names?.join(', ') || '-'
const retention = v.retention_days ? `${v.retention_days} Tage` : '-'
html += `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;">${name}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${v.description_de || '-'}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;font-family:monospace;font-size:11px;">${cookies}</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${retention}</td></tr>\n`
}
html += `</table>\n`
}
html += `</div>`
return html
}
export function EmbeddableVendorHTML({ siteId }: { siteId?: string }) {
const [vendors, setVendors] = useState<Vendor[]>([])
const [copied, setCopied] = useState(false)
useEffect(() => {
const sid = siteId || 'preview-test-site'
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
.then(r => r.ok ? r.json() : [])
.then(data => setVendors(Array.isArray(data) ? data : []))
.catch(() => {})
}, [siteId])
const html = generateHTML(vendors)
const handleCopy = () => {
navigator.clipboard.writeText(html)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">Einbettbarer HTML-Code</h3>
<p className="text-xs text-gray-500 mt-1">
Kopieren Sie diesen Code in Ihre Datenschutzerklaerung oder Cookie-Richtlinie.
</p>
</div>
<button onClick={handleCopy}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
{copied ? 'Kopiert!' : 'HTML kopieren'}
</button>
</div>
{/* Preview */}
<div className="border border-gray-200 rounded-lg p-4 bg-white">
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
{/* Raw HTML */}
<details className="group">
<summary className="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
Quellcode anzeigen
</summary>
<pre className="mt-2 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto max-h-[300px] overflow-y-auto">
{html}
</pre>
</details>
</div>
)
}
@@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
interface Site {
id: string
site_id: string
site_name: string
site_url: string
is_active: boolean
}
interface SiteSelectorProps {
sites: Site[]
activeSiteId: string | null
onSiteChange: (siteId: string) => void
onCreateSite: (data: { site_id: string; site_name: string; site_url: string }) => Promise<void>
}
export function SiteSelector({ sites, activeSiteId, onSiteChange, onCreateSite }: SiteSelectorProps) {
const [showCreate, setShowCreate] = useState(false)
const [newSite, setNewSite] = useState({ site_id: '', site_name: '', site_url: '' })
const [creating, setCreating] = useState(false)
const handleCreate = async () => {
if (!newSite.site_id || !newSite.site_name) return
setCreating(true)
try {
await onCreateSite(newSite)
setNewSite({ site_id: '', site_name: '', site_url: '' })
setShowCreate(false)
} finally {
setCreating(false)
}
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-500 mb-1">Website / Domain</label>
<select value={activeSiteId || ''} onChange={e => onSiteChange(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:ring-1 focus:ring-purple-500 bg-white">
{sites.length === 0 && <option value="">Keine Sites konfiguriert</option>}
{sites.map(s => (
<option key={s.site_id} value={s.site_id}>
{s.site_name} ({s.site_url || s.site_id})
</option>
))}
</select>
</div>
<button onClick={() => setShowCreate(!showCreate)}
className="mt-5 px-3 py-2 text-sm bg-purple-50 text-purple-600 border border-purple-200 rounded-lg hover:bg-purple-100">
+ Neue Seite
</button>
</div>
{showCreate && (
<div className="mt-4 pt-4 border-t border-gray-100 grid grid-cols-3 gap-3">
<input value={newSite.site_id} onChange={e => setNewSite({ ...newSite, site_id: e.target.value })}
placeholder="Site-ID (z.B. main-website)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<input value={newSite.site_name} onChange={e => setNewSite({ ...newSite, site_name: e.target.value })}
placeholder="Name (z.B. Hauptwebsite)" className="px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<div className="flex gap-2">
<input value={newSite.site_url} onChange={e => setNewSite({ ...newSite, site_url: e.target.value })}
placeholder="URL (z.B. https://example.com)" className="flex-1 px-3 py-2 text-sm border border-gray-200 rounded-lg" />
<button onClick={handleCreate} disabled={creating || !newSite.site_id}
className="px-3 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{creating ? '...' : 'Anlegen'}
</button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,161 @@
'use client'
import { useState, useEffect } from 'react'
interface IABPurpose {
id: number
name: string
name_de: string
}
const API = '/api/sdk/v1/compliance/tcf'
export function TCFSettings({ siteId, tcfEnabled, onToggle }: {
siteId?: string
tcfEnabled: boolean
onToggle: (enabled: boolean) => void
}) {
const [purposes, setPurposes] = useState<IABPurpose[]>([])
const [categoryMap, setCategoryMap] = useState<Record<string, number[]>>({})
const [testResult, setTestResult] = useState<string | null>(null)
const [testing, setTesting] = useState(false)
useEffect(() => {
Promise.all([
fetch(`${API}/purposes`).then(r => r.ok ? r.json() : []),
fetch(`${API}/category-mapping`).then(r => r.ok ? r.json() : {}),
]).then(([p, m]) => {
setPurposes(p)
setCategoryMap(m)
}).catch(() => {})
}, [])
const handleTestEncode = async () => {
setTesting(true)
setTestResult(null)
try {
const res = await fetch(`${API}/encode-categories`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ categories: ['necessary', 'statistics', 'marketing'] }),
})
if (res.ok) {
const data = await res.json()
setTestResult(`TC String: ${data.tc_string}\nPurposes: ${data.purposes_consented.join(', ')}`)
}
} catch { setTestResult('Fehler beim Generieren') }
setTesting(false)
}
return (
<div className="space-y-6">
{/* Enable/Disable */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">IAB TCF 2.2</h3>
<p className="text-xs text-gray-500 mt-1">
Transparency & Consent Framework Standardisierte Einwilligungssignale fuer programmatische Werbung
</p>
</div>
<label className="flex items-center gap-2">
<input type="checkbox" checked={tcfEnabled} onChange={e => onToggle(e.target.checked)}
className="w-5 h-5 text-purple-600 rounded" />
<span className="text-sm font-medium">{tcfEnabled ? 'Aktiv' : 'Inaktiv'}</span>
</label>
</div>
{!tcfEnabled && (
<p className="mt-3 text-xs text-amber-600 bg-amber-50 p-3 rounded-lg">
TCF ist nur erforderlich wenn Sie programmatische Werbung (AdTech) einsetzen.
Fuer die meisten Websites reicht das Standard-Cookie-Banner.
</p>
)}
</div>
{tcfEnabled && (
<>
{/* IAB Purposes */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-3">12 IAB-Zwecke (Purposes)</h4>
<p className="text-xs text-gray-500 mb-4">
Diese Zwecke werden automatisch aus Ihren Cookie-Kategorien abgeleitet.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{purposes.map(p => {
const activeCats = Object.entries(categoryMap)
.filter(([, pids]) => pids.includes(p.id))
.map(([cat]) => cat)
return (
<div key={p.id} className={`flex items-start gap-2 p-2 rounded-lg text-xs ${activeCats.length > 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold flex-shrink-0 ${activeCats.length > 0 ? 'bg-green-500 text-white' : 'bg-gray-300 text-white'}`}>
{p.id}
</span>
<div>
<div className="font-medium text-gray-700">{p.name_de}</div>
{activeCats.length > 0 && (
<div className="text-gray-400 mt-0.5">via: {activeCats.join(', ')}</div>
)}
</div>
</div>
)
})}
</div>
</div>
{/* Category Mapping */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-3">Kategorie Purpose Zuordnung</h4>
<div className="space-y-2">
{Object.entries(categoryMap).map(([cat, pids]) => (
<div key={cat} className="flex items-center gap-3">
<span className="text-sm font-medium text-gray-700 w-24 capitalize">{cat}</span>
<div className="flex gap-1 flex-wrap">
{pids.length === 0 ? (
<span className="text-xs text-gray-400">Keine Einwilligung noetig</span>
) : (
pids.map(pid => (
<span key={pid} className="px-2 py-0.5 text-[10px] bg-purple-100 text-purple-700 rounded-full">
Purpose {pid}
</span>
))
)}
</div>
</div>
))}
</div>
</div>
{/* TC String Test */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h4 className="font-semibold text-gray-900 mb-3">TC String testen</h4>
<button onClick={handleTestEncode} disabled={testing}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
{testing ? 'Generiere...' : 'Test TC String generieren'}
</button>
{testResult && (
<pre className="mt-3 p-3 bg-gray-50 border border-gray-200 rounded-lg text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap">
{testResult}
</pre>
)}
<p className="text-xs text-gray-400 mt-2">
Simuliert: necessary + statistics + marketing generiert base64url-codierten TC String
</p>
</div>
{/* CMP Registration Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<h4 className="font-semibold text-blue-800 text-sm">CMP-Registrierung</h4>
<p className="text-xs text-blue-700 mt-1">
Fuer den produktiven Einsatz muss Ihr CMP bei der IAB Europe registriert werden.
Sie erhalten eine eindeutige CMP-ID die im TC String codiert wird.
</p>
<p className="text-xs text-blue-600 mt-2">
Registrierung: <a href="https://iabeurope.eu/tcf-for-cmps/" target="_blank" rel="noopener"
className="underline">iabeurope.eu/tcf-for-cmps</a>
</p>
</div>
</>
)}
</div>
)
}
@@ -0,0 +1,138 @@
'use client'
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
interface Vendor {
id: string
vendor_name: string
vendor_url: string | null
category_key: string
description_de: string | null
description_en: string | null
cookie_names: string[]
retention_days: number | null
is_active: boolean
}
const CATEGORY_LABELS: Record<string, { label: string; color: string }> = {
necessary: { label: 'Notwendig', color: 'bg-green-100 text-green-700' },
functional: { label: 'Funktional', color: 'bg-blue-100 text-blue-700' },
statistics: { label: 'Statistik', color: 'bg-yellow-100 text-yellow-700' },
marketing: { label: 'Marketing', color: 'bg-red-100 text-red-700' },
}
export function VendorTable({ siteId }: { siteId?: string }) {
const { projectId } = useSDK()
const [vendors, setVendors] = useState<Vendor[]>([])
const [loading, setLoading] = useState(true)
const [expandedId, setExpandedId] = useState<string | null>(null)
useEffect(() => {
const sid = siteId || 'preview-test-site'
fetch(`/api/sdk/v1/banner/admin/sites/${sid}/vendors`)
.then(r => r.ok ? r.json() : [])
.then(data => setVendors(Array.isArray(data) ? data : []))
.catch(() => setVendors([]))
.finally(() => setLoading(false))
}, [siteId])
// Group by category
const grouped = vendors.reduce<Record<string, Vendor[]>>((acc, v) => {
const key = v.category_key || 'other'
if (!acc[key]) acc[key] = []
acc[key].push(v)
return acc
}, {})
if (loading) {
return <div className="text-center py-12 text-gray-400">Lade Verarbeiter...</div>
}
if (vendors.length === 0) {
return (
<div className="text-center py-12">
<p className="text-gray-400 mb-3">Keine Verarbeiter konfiguriert.</p>
<p className="text-xs text-gray-400">
Nutzen Sie den Website-Scanner oder fuegen Sie Verarbeiter manuell hinzu.
</p>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">Verarbeiter-Uebersicht</h3>
<p className="text-xs text-gray-500 mt-1">{vendors.length} Dienste in {Object.keys(grouped).length} Kategorien</p>
</div>
</div>
{Object.entries(grouped).map(([catKey, catVendors]) => {
const catInfo = CATEGORY_LABELS[catKey] || { label: catKey, color: 'bg-gray-100 text-gray-700' }
return (
<div key={catKey} className="border border-gray-200 rounded-xl overflow-hidden">
<div className="bg-gray-50 px-4 py-3 flex items-center gap-3">
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${catInfo.color}`}>
{catInfo.label}
</span>
<span className="text-xs text-gray-500">{catVendors.length} Dienste</span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 text-left text-xs text-gray-500">
<th className="px-4 py-2 font-medium">Anbieter</th>
<th className="px-4 py-2 font-medium">Zweck</th>
<th className="px-4 py-2 font-medium">Cookies</th>
<th className="px-4 py-2 font-medium">Aufbewahrung</th>
<th className="px-4 py-2 font-medium">Datenschutz</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{catVendors.map(v => (
<tr key={v.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5">
<button onClick={() => setExpandedId(expandedId === v.id ? null : v.id)}
className="font-medium text-gray-900 hover:text-purple-600 text-left">
{v.vendor_name}
</button>
{expandedId === v.id && v.cookie_names?.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{v.cookie_names.map(c => (
<span key={c} className="px-1.5 py-0.5 text-[10px] bg-gray-100 text-gray-600 rounded font-mono">
{c}
</span>
))}
</div>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-600 max-w-[200px] truncate">
{v.description_de || '-'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{v.cookie_names?.length || 0}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{v.retention_days ? `${v.retention_days} Tage` : '-'}
</td>
<td className="px-4 py-2.5">
{v.vendor_url ? (
<a href={v.vendor_url} target="_blank" rel="noopener noreferrer"
className="text-xs text-purple-600 hover:underline">
Link
</a>
) : (
<span className="text-xs text-gray-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
})}
</div>
)
}
@@ -96,13 +96,38 @@ const defaultBannerTexts: BannerTexts = {
privacyLink: '/datenschutz',
}
export interface BannerSite {
id: string
site_id: string
site_name: string
site_url: string
is_active: boolean
}
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)
const [sites, setSites] = useState<BannerSite[]>([])
const [activeSiteId, setActiveSiteId] = useState<string | null>(null)
// Load sites list
React.useEffect(() => {
fetch('/api/sdk/v1/banner/admin/sites')
.then(r => r.ok ? r.json() : [])
.then(data => {
const siteList = Array.isArray(data) ? data : []
setSites(siteList)
if (siteList.length > 0 && !activeSiteId) {
setActiveSiteId(siteList[0].site_id)
}
})
.catch(() => {})
}, [])
// Load config for active site
React.useEffect(() => {
const loadConfig = async () => {
try {
@@ -125,7 +150,20 @@ export function useCookieBanner() {
}
}
loadConfig()
}, [])
}, [activeSiteId])
const createSite = async (data: { site_id: string; site_name: string; site_url: string }) => {
const res = await fetch('/api/sdk/v1/banner/admin/sites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
const newSite = await res.json()
setSites(prev => [...prev, newSite])
setActiveSiteId(newSite.site_id || data.site_id)
}
}
const handleCategoryToggle = async (categoryId: string, enabled: boolean) => {
setCategories(prev =>
@@ -180,5 +218,6 @@ export function useCookieBanner() {
categories, config, bannerTexts, isSaving, exportToast,
setConfig, setBannerTexts,
handleCategoryToggle, handleExportCode, handleSaveConfig,
sites, activeSiteId, setActiveSiteId, createSite,
}
}
@@ -1,18 +1,28 @@
'use client'
import React from 'react'
import React, { useState } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { useCookieBanner } from './_hooks/useCookieBanner'
import { BannerPreview } from './_components/BannerPreview'
import { CategoryCard } from './_components/CategoryCard'
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'
import { TCFSettings } from './_components/TCFSettings'
type BannerTab = 'config' | 'vendors' | 'embed' | 'analytics' | 'abtest' | 'tcf'
export default function CookieBannerPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState<BannerTab>('config')
const {
categories, config, bannerTexts, isSaving, exportToast,
setConfig, setBannerTexts,
handleCategoryToggle, handleExportCode, handleSaveConfig,
sites, activeSiteId, setActiveSiteId, createSite,
} = useCookieBanner()
const totalCookies = categories.reduce((sum, cat) => sum + cat.cookies.length, 0)
@@ -57,6 +67,58 @@ export default function CookieBannerPage() {
</div>
</StepHeader>
{/* Site Selector */}
{sites.length > 0 && (
<SiteSelector sites={sites} activeSiteId={activeSiteId} onSiteChange={setActiveSiteId} onCreateSite={createSite} />
)}
{/* Tabs */}
<div className="flex border-b border-gray-200">
{([
{ id: 'config' as const, label: 'Konfiguration' },
{ 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' },
{ id: 'tcf' as const, label: 'TCF/IAB' },
]).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 ${
activeTab === tab.id ? 'text-purple-600 border-purple-600' : 'text-gray-500 border-transparent hover:text-gray-700'
}`}>
{tab.label}
</button>
))}
</div>
{/* Tab: Verarbeiter */}
{activeTab === 'vendors' && <VendorTable siteId={activeSiteId || undefined} />}
{/* Tab: Einbettung */}
{activeTab === 'embed' && <EmbeddableVendorHTML siteId={activeSiteId || undefined} />}
{/* Tab: Analytik */}
{activeTab === 'analytics' && <AnalyticsDashboard siteId={activeSiteId || undefined} />}
{/* Tab: A/B-Test */}
{activeTab === 'abtest' && <ABTestPanel siteConfigId={activeSiteId || undefined} />}
{/* Tab: TCF/IAB */}
{activeTab === 'tcf' && (
<TCFSettings siteId={activeSiteId || undefined} tcfEnabled={false}
onToggle={(enabled) => {
if (activeSiteId) {
fetch(`/api/sdk/v1/banner/admin/sites/${activeSiteId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tcf_enabled: enabled }),
})
}
}}
/>
)}
{/* Tab: Konfiguration */}
{activeTab !== 'config' ? null : (<>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
@@ -207,6 +269,7 @@ export default function CookieBannerPage() {
))}
</div>
</div>
</>)}
</div>
)
}
@@ -0,0 +1,354 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
CATEGORY_VENDORS, countNonEWRVendors, isEWR, isOutsideEWR,
} from '@/components/sdk/cookie-banner-vendors'
/**
* Cookie Banner Live-Vorschau simulates a real website with the banner.
*
* Purpose: Test the full consent flow end-to-end:
* 1. Visitor lands on simulated website banner appears
* 2. Visitor makes consent choice (accept/reject/custom + EWR toggle)
* 3. Consent is recorded via Banner API (POST /banner/consent)
* 4. Admin can verify in /sdk/consent-management and /sdk/einwilligungen
*
* This page runs OUTSIDE the SDK layout to simulate a real website experience.
*/
// Use Next.js API proxy to avoid SSL cert issues with direct backend calls
const API_BASE = '/api/sdk/v1/banner'
const SITE_ID = 'preview-test-site'
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
interface ConsentRecord {
id: string
categories: string[]
ewrOnly: boolean
blockedVendors: string[]
timestamp: string
device_fingerprint: string
}
function generateFingerprint(): string {
const nav = typeof navigator !== 'undefined' ? navigator : null
const seed = [
nav?.userAgent || '',
nav?.language || '',
screen?.width || 0,
screen?.height || 0,
new Date().getTimezoneOffset(),
].join('|')
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0
}
return `fp-${Math.abs(hash).toString(36)}-${Date.now().toString(36)}`
}
export default function CookieBannerPreviewPage() {
const [consent, setConsent] = useState<ConsentRecord | null>(null)
const [showBanner, setShowBanner] = useState(true)
const [ewrOnly, setEwrOnly] = useState(false)
const [categories, setCategories] = useState({ necessary: true, statistics: false, marketing: false, functional: false })
const [saving, setSaving] = useState(false)
const [apiResult, setApiResult] = useState<any>(null)
const [fingerprint] = useState(() => generateFingerprint())
// Check for existing consent on this simulated site
useEffect(() => {
async function check() {
try {
const res = await fetch(
`${API_BASE}/banner/consent?site_id=${SITE_ID}&device_fingerprint=${fingerprint}`,
{ headers: { 'x-tenant-id': TENANT_ID } },
)
if (res.ok) {
const data = await res.json()
if (data.has_consent) {
setConsent(data.consent)
setShowBanner(false)
}
}
} catch { /* first visit */ }
}
check()
}, [fingerprint])
const saveConsent = useCallback(async (cats: typeof categories) => {
setSaving(true)
const blocked: string[] = []
if (ewrOnly) {
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
if (!cats[key as keyof typeof cats]) continue
for (const v of cat.vendors) {
if (isOutsideEWR(v.country)) blocked.push(v.name)
}
}
}
try {
const res = await fetch(`${API_BASE}/banner/consent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-tenant-id': TENANT_ID },
body: JSON.stringify({
site_id: SITE_ID,
device_fingerprint: fingerprint,
categories: Object.entries(cats).filter(([, v]) => v).map(([k]) => k),
vendors: [],
consent_string: JSON.stringify({ ewrOnly, blockedVendors: blocked }),
user_agent: navigator.userAgent,
}),
})
const data = await res.json()
setApiResult(data)
setConsent({ ...data, ewrOnly, blockedVendors: blocked, timestamp: new Date().toISOString() })
setShowBanner(false)
} catch (err: any) {
setApiResult({ error: err.message })
// Close banner even on error — don't trap the user
setShowBanner(false)
}
setSaving(false)
}, [ewrOnly, fingerprint])
const nonEWRCount = countNonEWRVendors()
return (
<div className="min-h-screen bg-white">
{/* Simulated Website Header */}
<header className="bg-slate-800 text-white px-8 py-4">
<div className="max-w-6xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-500 rounded-lg" />
<span className="font-semibold text-lg">MusterShop GmbH</span>
</div>
<nav className="flex items-center gap-6 text-sm text-slate-300">
<span className="hover:text-white cursor-pointer">Produkte</span>
<span className="hover:text-white cursor-pointer">Ueber uns</span>
<span className="hover:text-white cursor-pointer">Kontakt</span>
<span className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm">Warenkorb (2)</span>
</nav>
</div>
</header>
{/* Simulated Website Content */}
<main className="max-w-6xl mx-auto px-8 py-12">
<div className="grid grid-cols-3 gap-8">
<div className="col-span-2 space-y-6">
<h1 className="text-3xl font-bold text-gray-900">Willkommen bei MusterShop</h1>
<p className="text-gray-600 leading-relaxed">
Dies ist eine simulierte Website um den Cookie-Banner zu testen.
Die Consent-Daten werden ueber die echte Banner-API gespeichert und
erscheinen in Ihrem CMP unter Consent-Records und Consent-Verwaltung.
</p>
<div className="grid grid-cols-2 gap-4">
{['Premium Paket', 'Standard Paket', 'Starter Paket', 'Enterprise'].map(p => (
<div key={p} className="bg-gray-50 border border-gray-200 rounded-xl p-6">
<div className="w-full h-24 bg-gray-200 rounded-lg mb-3" />
<h3 className="font-semibold text-gray-900">{p}</h3>
<p className="text-sm text-gray-500 mt-1">Lorem ipsum dolor sit amet</p>
</div>
))}
</div>
</div>
{/* API Debug Panel */}
<div className="space-y-4">
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
<h3 className="font-semibold text-slate-800 text-sm flex items-center gap-2">
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
API Debug
</h3>
<div className="mt-3 space-y-2 text-xs">
<div className="flex justify-between">
<span className="text-slate-500">Site ID</span>
<code className="text-slate-700">{SITE_ID}</code>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Fingerprint</span>
<code className="text-slate-700 truncate ml-2">{fingerprint}</code>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Consent</span>
<span className={consent ? 'text-green-600 font-medium' : 'text-amber-600'}>
{consent ? 'Gespeichert' : 'Ausstehend'}
</span>
</div>
{consent && (
<>
<div className="flex justify-between">
<span className="text-slate-500">Kategorien</span>
<span className="text-slate-700">{consent.categories?.join(', ')}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">EWR-Only</span>
<span className={consent.ewrOnly ? 'text-blue-600' : 'text-slate-400'}>
{consent.ewrOnly ? 'Ja' : 'Nein'}
</span>
</div>
{consent.blockedVendors?.length > 0 && (
<div>
<span className="text-slate-500">Blockiert:</span>
<div className="mt-1 flex flex-wrap gap-1">
{consent.blockedVendors.map(v => (
<span key={v} className="px-1.5 py-0.5 bg-red-100 text-red-700 rounded text-[10px]">{v}</span>
))}
</div>
</div>
)}
</>
)}
</div>
{consent && (
<button
onClick={() => { setConsent(null); setShowBanner(true); setApiResult(null) }}
className="mt-3 w-full text-xs text-purple-600 hover:text-purple-700 underline"
>
Consent zuruecksetzen (Banner erneut anzeigen)
</button>
)}
</div>
{apiResult && (
<div className="bg-slate-900 text-green-400 rounded-xl p-4 text-xs font-mono overflow-auto max-h-48">
<div className="text-slate-500 mb-1">POST /banner/consent Response:</div>
{JSON.stringify(apiResult, null, 2)}
</div>
)}
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4 text-xs text-purple-800">
<div className="font-semibold mb-1">Pruefen Sie das Ergebnis in:</div>
<ul className="space-y-1 mt-2">
<li><a href="/sdk/consent-management" className="underline hover:text-purple-600">Consent-Verwaltung</a></li>
<li><a href="/sdk/einwilligungen" className="underline hover:text-purple-600">Consent-Records</a></li>
<li><a href="/sdk/dsr" className="underline hover:text-purple-600">DSR Portal</a></li>
</ul>
</div>
</div>
</div>
</main>
{/* Simulated Website Footer */}
<footer className="bg-slate-100 border-t border-slate-200 px-8 py-6 mt-12">
<div className="max-w-6xl mx-auto flex items-center justify-between text-sm text-slate-500">
<span>MusterShop GmbH Simulierte Test-Website</span>
<div className="flex items-center gap-4">
<button onClick={() => setShowBanner(true)} className="underline hover:text-purple-600">
Cookie-Einstellungen
</button>
<span>Datenschutz</span>
<span>Impressum</span>
</div>
</div>
</footer>
{/* === REAL COOKIE BANNER === */}
{showBanner && (
<>
<div className="fixed inset-0 bg-black/40 z-[9998]" />
<div className="fixed bottom-0 left-0 right-0 z-[9999]">
<div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden">
{/* Header */}
<div className="px-6 pt-5 pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h2 className="text-lg font-semibold text-gray-900">Cookie-Einstellungen</h2>
<p className="text-sm text-gray-600 mt-1">
Waehlen Sie, welche Cookie-Kategorien Sie zulassen moechten.
</p>
</div>
{/* EWR Toggle */}
<div className="flex flex-col items-end gap-1 shrink-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${ewrOnly ? 'text-blue-700' : 'text-gray-500'}`}>
Nur EU/EWR
</span>
<button
onClick={() => setEwrOnly(!ewrOnly)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors cursor-pointer ${
ewrOnly ? 'bg-blue-600' : 'bg-gray-200'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
ewrOnly ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
</div>
</div>
{/* Categories */}
<div className="px-6 pb-3 space-y-1.5 max-h-[40vh] overflow-y-auto border-t border-gray-100 pt-3">
{Object.entries(CATEGORY_VENDORS).map(([key, cat]) => {
const checked = key === 'necessary' ? true : categories[key as keyof typeof categories]
const nonEU = cat.vendors.filter(v => isOutsideEWR(v.country))
const blocked = ewrOnly && checked ? nonEU.length : 0
return (
<div key={key} className="flex items-center justify-between gap-3 px-4 py-2.5 border border-gray-100 rounded-lg bg-gray-50/50">
<div>
<div className="text-sm font-medium text-gray-900">
{cat.label}
<span className="ml-2 text-xs font-normal text-gray-400">
{blocked > 0 ? `${cat.vendors.length - blocked} aktiv, ${blocked} blockiert` : `${cat.vendors.length} Verarbeiter`}
</span>
</div>
<div className="text-xs text-gray-500">{cat.description}</div>
</div>
<button
onClick={() => key !== 'necessary' && setCategories(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }))}
disabled={key === 'necessary'}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 ${
checked ? (key === 'necessary' ? 'bg-gray-400' : 'bg-purple-600') : 'bg-gray-200'
} ${key === 'necessary' ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow-sm transition-transform ${
checked ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
)
})}
</div>
{/* Buttons */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100">
<div className="flex items-center gap-3">
<button
onClick={() => saveConsent({ necessary: true, statistics: true, marketing: true, functional: true })}
disabled={saving}
className="flex-1 px-4 py-2.5 bg-purple-600 text-white rounded-lg font-medium hover:bg-purple-700 transition-colors text-sm disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Alle akzeptieren'}
</button>
<button
onClick={() => saveConsent(categories)}
disabled={saving}
className="flex-1 px-4 py-2.5 bg-gray-200 text-gray-700 rounded-lg font-medium hover:bg-gray-300 transition-colors text-sm disabled:opacity-50"
>
Auswahl speichern
</button>
</div>
<div className="flex items-center justify-between mt-3">
<button
onClick={() => saveConsent({ necessary: true, statistics: false, marketing: false, functional: false })}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Nur notwendige Cookies
</button>
<div className="flex items-center gap-3 text-xs text-gray-400">
<span>Datenschutzerklaerung</span>
<span>Impressum</span>
</div>
</div>
</div>
</div>
</div>
</>
)}
</div>
)
}
@@ -1,7 +1,9 @@
'use client'
import { useState } from 'react'
import { LegalTemplateResult } from '@/lib/sdk/types'
import { RuleEngineResult } from '../ruleEngine'
import ReviewAssignmentPanel from './ReviewAssignmentPanel'
interface GeneratorPreviewTabProps {
template: LegalTemplateResult
@@ -10,8 +12,76 @@ interface GeneratorPreviewTabProps {
missing: string[]
onCopy: () => void
onExportMarkdown: () => void
onSaveToWorkflow?: () => void
saveStatus?: string | null
}
// ============================================================================
// Lightweight Markdown → HTML (no dependency needed)
// ============================================================================
function markdownToHtml(md: string): string {
let html = md
// Escape HTML entities first
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// Headings
html = html.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// Horizontal rules
html = html.replace(/^---$/gm, '<hr/>')
// Bold + Italic
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="text-purple-600 underline">$1</a>')
// Tables (simple)
html = html.replace(/^\|(.+)\|$/gm, (match) => {
const cells = match.split('|').filter(c => c.trim())
const isHeader = cells.every(c => /^[\s-:]+$/.test(c))
if (isHeader) return '<!-- separator -->'
const tag = 'td'
return '<tr>' + cells.map(c => `<${tag}>${c.trim()}</${tag}>`).join('') + '</tr>'
})
// Wrap consecutive table rows
html = html.replace(/((?:<tr>.*<\/tr>\n?<!-- separator -->\n?)?(?:<tr>.*<\/tr>\n?)+)/g, (block) => {
const rows = block.split('\n').filter(r => r.startsWith('<tr>'))
if (rows.length === 0) return block
const headerRow = rows[0].replace(/<td>/g, '<th>').replace(/<\/td>/g, '</th>')
const bodyRows = rows.slice(1).join('\n')
return `<table><thead>${headerRow}</thead><tbody>${bodyRows}</tbody></table>`
})
// Remove separator comments
html = html.replace(/<!-- separator -->\n?/g, '')
// Unordered lists
html = html.replace(/^- (.+)$/gm, '<li>$1</li>')
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>')
// Paragraphs (lines that aren't already HTML)
html = html.replace(/^(?!<[a-z/]|$)(.+)$/gm, '<p>$1</p>')
// Clean up empty paragraphs
html = html.replace(/<p>\s*<\/p>/g, '')
return html
}
// ============================================================================
// Component
// ============================================================================
export default function GeneratorPreviewTab({
template,
ruleResult,
@@ -19,13 +89,20 @@ export default function GeneratorPreviewTab({
missing,
onCopy,
onExportMarkdown,
onSaveToWorkflow,
saveStatus,
}: GeneratorPreviewTabProps) {
const [viewMode, setViewMode] = useState<'preview' | 'markdown'>('preview')
const htmlContent = markdownToHtml(renderedContent)
return (
<div className="space-y-4">
{/* Violations */}
{ruleResult && ruleResult.violations.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
<p className="text-sm font-semibold text-red-700 mb-2">
🔴 {ruleResult.violations.length} Fehler
{ruleResult.violations.length} Fehler
</p>
<ul className="space-y-1">
{ruleResult.violations.map((v) => (
@@ -36,6 +113,8 @@ export default function GeneratorPreviewTab({
</ul>
</div>
)}
{/* Warnings */}
{ruleResult && ruleResult.warnings.filter((w) => w.id !== 'WARN_LEGAL_REVIEW').length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
<ul className="space-y-1">
@@ -43,69 +122,156 @@ export default function GeneratorPreviewTab({
.filter((w) => w.id !== 'WARN_LEGAL_REVIEW')
.map((w) => (
<li key={w.id} className="text-xs text-yellow-700">
🟡 <span className="font-mono font-medium">[{w.id}]</span> {w.message}
<span className="font-mono font-medium">[{w.id}]</span> {w.message}
</li>
))}
</ul>
</div>
)}
{/* Legal notice */}
{ruleResult && (
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3">
<p className="text-xs text-blue-700">
Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
wird eine rechtliche Überprüfung dringend empfohlen.
Rechtlicher Hinweis: Diese Vorlage ist MIT-lizenziert. Vor Produktionseinsatz
wird eine rechtliche Ueberpruefung dringend empfohlen.
</p>
</div>
)}
{ruleResult && ruleResult.appliedDefaults.length > 0 && (
<p className="text-xs text-gray-400">
Defaults angewendet: {ruleResult.appliedDefaults.join(', ')}
</p>
)}
{/* Toolbar */}
<div className="flex items-center justify-between flex-wrap gap-2">
<span className="text-sm text-gray-600">
{missing.length > 0 && (
<span className="text-orange-600">
{missing.length} Platzhalter noch nicht ausgefüllt
</span>
)}
</span>
<div className="flex gap-2">
<div className="flex gap-1 bg-gray-100 rounded-lg p-0.5">
<button
onClick={onCopy}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
onClick={() => setViewMode('preview')}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
viewMode === 'preview' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Kopieren
Vorschau
</button>
<button
onClick={onExportMarkdown}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600 transition-colors"
onClick={() => setViewMode('markdown')}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
viewMode === 'markdown' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Markdown
</button>
</div>
<div className="flex items-center gap-2">
{missing.length > 0 && (
<span className="text-xs text-orange-600">
{missing.length} Platzhalter offen
</span>
)}
<button onClick={onCopy} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
Kopieren
</button>
<button onClick={onExportMarkdown} className="px-3 py-1.5 text-xs border border-gray-200 rounded-lg hover:bg-gray-50 text-gray-600">
Markdown
</button>
<button
onClick={() => window.print()}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
onClick={() => {
const printWindow = window.open('', '_blank')
if (!printWindow) return
printWindow.document.write(`<!DOCTYPE html><html><head><title>${template.documentTitle || 'Dokument'}</title><style>
@page { size: A4; margin: 25mm 20mm; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 11pt; line-height: 1.6; color: #1a202c; max-width: 170mm; margin: 0 auto; }
h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
ul { padding-left: 20pt; }
li { margin: 2pt 0; }
hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
a { color: #7c3aed; }
p { margin: 4pt 0; }
strong { font-weight: 600; }
</style></head><body>${htmlContent}</body></html>`)
printWindow.document.close()
printWindow.print()
}}
className="px-4 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
PDF drucken
</button>
{onSaveToWorkflow && (
<button
onClick={onSaveToWorkflow}
disabled={saveStatus === 'saving'}
className={`px-4 py-1.5 text-xs rounded-lg transition-colors ${
saveStatus === 'saved' ? 'bg-green-600 text-white' :
saveStatus === 'error' ? 'bg-red-600 text-white' :
'bg-indigo-600 text-white hover:bg-indigo-700'
} disabled:opacity-50`}
>
{saveStatus === 'saving' ? 'Speichern...' :
saveStatus === 'saved' ? 'Gespeichert!' :
saveStatus === 'error' ? 'Fehler' :
'Als Version speichern'}
</button>
)}
</div>
</div>
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[600px] overflow-y-auto">
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-sans">
{renderedContent}
</pre>
</div>
{/* Content */}
{viewMode === 'markdown' ? (
<div className="bg-gray-50 rounded-xl border border-gray-200 p-6 max-h-[800px] overflow-y-auto">
<pre className="text-sm text-gray-800 whitespace-pre-wrap leading-relaxed font-mono">
{renderedContent}
</pre>
</div>
) : (
<div className="bg-gray-100 rounded-xl p-8 flex justify-center overflow-y-auto max-h-[85vh]">
{/* A4 Page */}
<div
className="bg-white shadow-lg border border-gray-300"
style={{
width: '210mm',
minHeight: '297mm',
padding: '25mm 20mm',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
fontSize: '11pt',
lineHeight: '1.6',
color: '#1a202c',
}}
>
<style>{`
.a4-content h1 { font-size: 18pt; color: #5b21b6; margin: 24pt 0 8pt; border-bottom: 2px solid #7c3aed; padding-bottom: 4pt; }
.a4-content h2 { font-size: 14pt; color: #1f2937; margin: 18pt 0 6pt; }
.a4-content h3 { font-size: 12pt; color: #374151; margin: 12pt 0 4pt; }
.a4-content h4 { font-size: 11pt; color: #4b5563; margin: 10pt 0 4pt; }
.a4-content table { width: 100%; border-collapse: collapse; margin: 8pt 0; font-size: 10pt; }
.a4-content th { background: #f5f3ff; color: #5b21b6; font-weight: 600; text-align: left; padding: 6pt 8pt; border: 1px solid #e5e7eb; }
.a4-content td { padding: 5pt 8pt; border: 1px solid #e5e7eb; vertical-align: top; }
.a4-content ul { padding-left: 20pt; margin: 4pt 0; }
.a4-content li { margin: 2pt 0; }
.a4-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 16pt 0; }
.a4-content a { color: #7c3aed; text-decoration: underline; }
.a4-content p { margin: 4pt 0; }
.a4-content strong { font-weight: 600; }
`}</style>
<div
className="a4-content"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
</div>
</div>
)}
{/* Review Assignment */}
<ReviewAssignmentPanel
documentType={template.templateType || ''}
documentTitle={template.documentTitle || 'Dokument'}
documentContent={renderedContent}
/>
{/* Attribution */}
{template.attributionRequired && template.attributionText && (
<div className="text-xs text-orange-600 bg-orange-50 p-3 rounded-lg border border-orange-200">
<strong>Attribution erforderlich:</strong> {template.attributionText}
@@ -38,7 +38,7 @@ export default function GeneratorSection({
const [activeTab, setActiveTab] = useState<'placeholders' | 'preview'>('placeholders')
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['PROVIDER', 'LEGAL']))
const placeholders = template.placeholders || []
const placeholders = Array.isArray(template.placeholders) ? template.placeholders : []
const relevantSections = useMemo(() => getRelevantSections(placeholders), [placeholders])
const uncovered = useMemo(() => getUncoveredPlaceholders(placeholders, context), [placeholders, context])
const missing = useMemo(() => getMissingRequired(placeholders, context), [placeholders, context])
@@ -101,6 +101,45 @@ export default function GeneratorSection({
const handleCopy = () => navigator.clipboard.writeText(renderedContent)
const [saveStatus, setSaveStatus] = useState<string | null>(null)
const handleSaveToWorkflow = async () => {
setSaveStatus('saving')
try {
// 1. Create or find document
const docRes = await fetch('/api/sdk/v1/compliance/legal-documents/documents', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: template.templateType || 'custom',
name: template.documentTitle || 'Dokument',
description: `Generiert aus Template: ${template.templateType}`,
}),
})
if (!docRes.ok) throw new Error('Dokument konnte nicht erstellt werden')
const doc = await docRes.json()
// 2. Create version
const verRes = await fetch('/api/sdk/v1/compliance/legal-documents/versions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
document_id: doc.id,
title: template.documentTitle || 'Dokument',
content: renderedContent,
language: template.language || 'de',
version: '1.0',
}),
})
if (!verRes.ok) throw new Error('Version konnte nicht erstellt werden')
setSaveStatus('saved')
setTimeout(() => setSaveStatus(null), 3000)
} catch (e) {
setSaveStatus('error')
setTimeout(() => setSaveStatus(null), 3000)
}
}
const handleExportMarkdown = () => {
const blob = new Blob([renderedContent], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
@@ -160,6 +199,33 @@ export default function GeneratorSection({
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={() => {
// Load example data for current template type
const templateType = template.templateType || ''
const lang = template.language || 'de'
const exampleFile = `/sdk/document-generator/examples/${templateType}_${lang}.json`
fetch(exampleFile)
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data?.context) return
const ctx = data.context
for (const [section, fields] of Object.entries(ctx)) {
if (typeof fields === 'object' && fields) {
for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
onContextChange(section as keyof TemplateContext, key, value)
}
}
}
})
.catch(() => {/* no example available */})
}}
className="px-3 py-1 text-xs bg-blue-50 text-blue-600 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
>
Beispieldaten
</button>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors shrink-0" aria-label="Schließen">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@@ -223,6 +289,8 @@ export default function GeneratorSection({
missing={missing}
onCopy={handleCopy}
onExportMarkdown={handleExportMarkdown}
onSaveToWorkflow={handleSaveToWorkflow}
saveStatus={saveStatus}
/>
)}
</div>
@@ -0,0 +1,130 @@
'use client'
import { useMemo, useState } from 'react'
import { useSDK } from '@/lib/sdk'
import { evaluateTemplateRecommendations, type TemplateRecommendation } from '../templateRecommendations'
import { getProfileLabel } from '../scopeDefaults'
import type { LegalTemplateResult } from '@/lib/sdk/types'
import type { ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types/core-levels'
interface Props {
allTemplates: LegalTemplateResult[]
onUseTemplate: (t: LegalTemplateResult) => void
}
export default function RecommendedDocuments({ allTemplates, onUseTemplate }: Props) {
const { state } = useSDK()
const [showOptional, setShowOptional] = useState(false)
const level = state?.complianceScope?.determinedLevel as ComplianceDepthLevel | undefined
const scopeAnswers = state?.complianceScope?.answers || []
const recommendations = useMemo(() => {
if (!level) return null
return evaluateTemplateRecommendations(
scopeAnswers,
level,
(state?.companyProfile as Record<string, unknown>) || {},
)
}, [level, scopeAnswers, state?.companyProfile])
if (!level || !recommendations || recommendations.length === 0) return null
// Match recommendations to actual templates in the library
const templateMap = new Map<string, LegalTemplateResult>()
for (const t of allTemplates) {
if (t.templateType) templateMap.set(t.templateType, t)
}
const required = recommendations.filter((r) => r.requirement === 'required')
const recommended = recommendations.filter((r) => r.requirement === 'recommended')
const optional = recommendations.filter((r) => r.requirement === 'optional')
const renderCard = (rec: TemplateRecommendation) => {
const template = templateMap.get(rec.templateType)
const exists = !!template
return (
<div
key={rec.templateType}
className={`rounded-lg border p-3 text-sm ${
exists
? 'border-gray-200 bg-white hover:border-purple-300 cursor-pointer'
: 'border-dashed border-gray-300 bg-gray-50'
}`}
onClick={() => exists && template && onUseTemplate(template)}
>
<div className="font-medium text-gray-900 truncate">{rec.label}</div>
<div className="text-xs text-gray-500 mt-1">
{exists ? (
<span className="text-purple-600">Vorlage verfuegbar</span>
) : (
<span className="text-gray-400">Noch nicht erstellt</span>
)}
</div>
</div>
)
}
return (
<div className="bg-gradient-to-br from-purple-50 to-white rounded-xl border border-purple-200 p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Empfohlene Dokumente fuer Ihr Unternehmen
</h3>
<p className="text-sm text-gray-500 mt-1">
Basierend auf Ihrem Compliance-Profil ({getProfileLabel(level)})
</p>
</div>
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700">
{level}
</span>
</div>
{/* Required */}
{required.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-red-700">Pflicht</span>
<span className="text-xs text-gray-400">({required.length})</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
{required.map(renderCard)}
</div>
</div>
)}
{/* Recommended */}
{recommended.length > 0 && (
<div className="mb-4">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-amber-700">Empfohlen</span>
<span className="text-xs text-gray-400">({recommended.length})</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2">
{recommended.map(renderCard)}
</div>
</div>
)}
{/* Optional (collapsed by default) */}
{optional.length > 0 && (
<div>
<button
onClick={() => setShowOptional(!showOptional)}
className="text-sm text-gray-500 hover:text-purple-600 flex items-center gap-1"
>
<span>{showOptional ? '▼' : '▶'}</span>
<span>Optional ({optional.length})</span>
</button>
{showOptional && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2 mt-2">
{optional.map(renderCard)}
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,170 @@
'use client'
import { useState, useEffect } from 'react'
import { useSDK } from '@/lib/sdk'
interface ReviewerInfo {
role_key: string
role_label?: string
person_name?: string | null
person_email?: string | null
is_primary?: boolean
}
interface ReviewRecord {
id: string
status: string
reviewer_role_key: string
reviewer_name: string | null
email_sent: boolean
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-gray-100 text-gray-700',
in_review: 'bg-blue-100 text-blue-700',
approved: 'bg-green-100 text-green-700',
rejected: 'bg-red-100 text-red-700',
}
const STATUS_LABELS: Record<string, string> = {
pending: 'Ausstehend',
in_review: 'In Pruefung',
approved: 'Freigegeben',
rejected: 'Abgelehnt',
}
export default function ReviewAssignmentPanel({
documentType,
documentTitle,
documentContent,
}: {
documentType: string
documentTitle: string
documentContent: string
}) {
const { projectId } = useSDK()
const [reviewers, setReviewers] = useState<ReviewerInfo[]>([])
const [existingReviews, setExistingReviews] = useState<ReviewRecord[]>([])
const [sending, setSending] = useState(false)
const [result, setResult] = useState<string | null>(null)
// Load reviewers for this document type
useEffect(() => {
if (!documentType) return
const qs = new URLSearchParams()
if (projectId) qs.set('project_id', projectId)
qs.set('document_type', documentType)
// Load mapping + existing reviews
Promise.all([
fetch(`/api/sdk/v1/compliance/org-roles/mapping`).then(r => r.ok ? r.json() : []),
fetch(`/api/sdk/v1/compliance/org-roles${projectId ? `?project_id=${projectId}` : ''}`).then(r => r.ok ? r.json() : []),
fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.ok ? r.json() : []),
]).then(([mappings, roles, reviews]) => {
// Filter mappings for this document type
const relevant = (mappings as Array<{ document_type: string; role_key: string; is_primary: boolean }>)
.filter(m => m.document_type === documentType)
// Enrich with role info
const enriched: ReviewerInfo[] = relevant.map(m => {
const role = (roles as Array<{ role_key: string; role_label: string; person_name: string | null; person_email: string | null }>)
.find(r => r.role_key === m.role_key)
return { ...m, role_label: role?.role_label, person_name: role?.person_name, person_email: role?.person_email }
})
setReviewers(enriched)
setExistingReviews(reviews)
}).catch(() => {})
}, [documentType, projectId])
const handleSendForReview = async () => {
setSending(true)
setResult(null)
try {
const res = await fetch('/api/sdk/v1/compliance/document-reviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
document_type: documentType,
document_title: documentTitle,
document_content: documentContent,
project_id: projectId,
review_link: window.location.href,
}),
})
if (!res.ok) throw new Error('Fehler beim Erstellen')
const reviews = await res.json()
// Send email for each review
let sentCount = 0
for (const review of reviews) {
if (review.reviewer_email) {
const sendRes = await fetch(`/api/sdk/v1/compliance/document-reviews/${review.id}/send`, { method: 'POST' })
if (sendRes.ok) sentCount++
}
}
setResult(`${reviews.length} Review(s) erstellt, ${sentCount} E-Mail(s) gesendet`)
// Refresh
const qs = new URLSearchParams({ document_type: documentType })
if (projectId) qs.set('project_id', projectId)
const updated = await fetch(`/api/sdk/v1/compliance/document-reviews/for-document?${qs}`).then(r => r.json())
setExistingReviews(updated)
} catch (e) {
setResult(e instanceof Error ? e.message : 'Fehler')
} finally {
setSending(false)
}
}
if (reviewers.length === 0 && existingReviews.length === 0) return null
return (
<div className="border border-purple-200 rounded-lg p-4 bg-purple-50/50 space-y-3">
<h4 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
<svg className="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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 0z" />
</svg>
Pruefung & Freigabe
</h4>
{/* Assigned reviewers */}
{reviewers.length > 0 && (
<div className="space-y-1">
{reviewers.map(r => (
<div key={r.role_key} className="flex items-center gap-2 text-xs">
<span className="font-medium text-gray-700">{r.role_label || r.role_key}:</span>
{r.person_name ? (
<span className="text-gray-600">{r.person_name} ({r.person_email || 'keine E-Mail'})</span>
) : (
<span className="text-gray-400 italic">Nicht zugewiesen</span>
)}
</div>
))}
</div>
)}
{/* Existing reviews */}
{existingReviews.length > 0 && (
<div className="space-y-1">
{existingReviews.map(r => (
<div key={r.id} className="flex items-center gap-2">
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${STATUS_COLORS[r.status] || ''}`}>
{STATUS_LABELS[r.status] || r.status}
</span>
<span className="text-xs text-gray-600">{r.reviewer_name || r.reviewer_role_key}</span>
{r.email_sent && <span className="text-[10px] text-green-600">E-Mail gesendet</span>}
</div>
))}
</div>
)}
{/* Send for review */}
<button onClick={handleSendForReview} disabled={sending || reviewers.length === 0}
className="w-full px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors">
{sending ? 'Sende...' : 'Zur Pruefung senden'}
</button>
{result && (
<p className={`text-xs ${result.includes('Fehler') ? 'text-red-600' : 'text-green-600'}`}>{result}</p>
)}
</div>
)
}
@@ -6,22 +6,64 @@ import { TemplateContext } from './contextBridge'
export const CATEGORIES: { key: string; label: string; types: string[] | null }[] = [
{ key: 'all', label: 'Alle', types: null },
{ key: 'privacy_policy', label: 'Datenschutz', types: ['privacy_policy'] },
{ key: 'terms', label: 'AGB', types: ['terms_of_service', 'agb', 'clause'] },
{ key: 'impressum', label: 'Impressum', types: ['impressum'] },
{ key: 'dpa', label: 'AVV/DPA', types: ['dpa'] },
{ key: 'nda', label: 'NDA', types: ['nda'] },
{ key: 'sla', label: 'SLA', types: ['sla'] },
{ key: 'acceptable_use', label: 'AUP', types: ['acceptable_use'] },
{ key: 'widerruf', label: 'Widerruf', types: ['widerruf'] },
{ key: 'cookie', label: 'Cookie', types: ['cookie_policy', 'cookie_banner'] },
{ key: 'cloud', label: 'Cloud', types: ['cloud_service_agreement'] },
{ key: 'misc', label: 'Weitere', types: ['community_guidelines', 'copyright_policy', 'data_usage_clause'] },
{ key: 'dsfa', label: 'DSFA', types: ['dsfa'] },
{ key: 'dsr', label: 'DSR-Prozesse', types: [
// ── Nach Nutzungskontext sortiert ──────────────────────────────────────
// Jede Website / App braucht:
{ key: 'website', label: 'Website / App', types: ['privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner', 'social_media_dsi'] },
// Online-Shop / E-Commerce:
{ key: 'shop', label: 'Online-Shop', types: ['agb', 'widerruf', 'privacy_policy', 'impressum', 'cookie_policy', 'cookie_banner'] },
// SaaS / Cloud-Dienst:
{ key: 'saas', label: 'SaaS / Cloud', types: ['agb', 'dpa', 'sla', 'cloud_service_agreement', 'privacy_policy', 'terms_of_use'] },
// App / Plattform mit Nutzern:
{ key: 'platform', label: 'App / Plattform', types: ['terms_of_use', 'community_guidelines', 'privacy_policy', 'agb', 'acceptable_use', 'media_content_policy', 'copyright_policy'] },
// Vertraege mit Geschaeftspartnern:
{ key: 'contracts', label: 'Vertraege (B2B)', types: ['dpa', 'nda', 'sla', 'cloud_service_agreement', 'data_usage_clause'] },
// Drittlandtransfer:
{ key: 'third_country', label: 'Drittlandtransfer', types: ['transfer_impact_assessment', 'scc_companion'] },
// ── Interne Compliance-Dokumente ──────────────────────────────────────
// DSGVO-Kernpflichten:
{ key: 'dsgvo_core', label: 'DSGVO-Pflichten', types: ['tom_documentation', 'vvt_register', 'loeschkonzept', 'dsfa', 'pflichtenregister'] },
// Betroffenenrechte:
{ key: 'dsr', label: 'Betroffenenrechte', types: [
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
]},
// Datenschutz-Informationen (alle DSI-Typen):
{ key: 'dsi', label: 'Datenschutzinfos', types: ['privacy_policy', 'applicant_dsi', 'employee_dsi', 'social_media_dsi', 'video_conference_dsi', 'informationspflichten'] },
// Einwilligungen:
{ key: 'consent', label: 'Einwilligungen', types: ['consent_texts', 'cookie_banner', 'verpflichtungserklaerung'] },
// ── Sicherheit & IT ───────────────────────────────────────────────────
{ key: 'security_concepts', label: 'Sicherheitskonzepte', types: ['it_security_concept', 'data_protection_concept', 'backup_recovery_concept', 'logging_concept', 'incident_response_plan', 'access_control_concept', 'risk_management_concept', 'isms_manual'] },
{ key: 'security_policies', label: 'Sicherheitsrichtlinien', types: [
'information_security_policy', 'access_control_policy', 'password_policy', 'encryption_policy',
'cybersecurity_policy', 'incident_response_policy', 'logging_policy', 'patch_management_policy',
'vulnerability_management_policy', 'secrets_management_policy', 'devsecops_policy',
'cloud_security_policy', 'change_management_policy', 'asset_management_policy', 'backup_policy',
]},
// ── Organisation & HR ─────────────────────────────────────────────────
{ key: 'hr', label: 'HR & Mitarbeiter', types: ['applicant_dsi', 'employee_dsi', 'employee_security_policy', 'security_awareness_policy', 'remote_work_policy', 'offboarding_policy', 'byod_policy', 'ai_usage_policy', 'whistleblower_policy', 'verpflichtungserklaerung'] },
{ key: 'data_governance', label: 'Daten-Governance', types: ['data_protection_policy', 'data_classification_policy', 'data_retention_policy', 'data_transfer_policy', 'privacy_incident_policy'] },
{ key: 'vendor', label: 'Lieferanten / Vendor', types: ['vendor_risk_management_policy', 'third_party_security_policy', 'supplier_security_policy', 'dpa'] },
{ key: 'bcm', label: 'BCM / Notfall', types: ['business_continuity_policy', 'disaster_recovery_policy', 'crisis_management_policy', 'incident_response_plan'] },
]
// =============================================================================
@@ -41,6 +83,8 @@ export const SECTION_LABELS: Record<keyof TemplateContext, string> = {
CONSENT: 'Cookie / Einwilligung',
HOSTING: 'Hosting-Provider',
FEATURES: 'Dokument-Features & Textbausteine',
TOM: 'TOM-Dokumentation',
DPA: 'AVV / Auftragsverarbeitung',
}
export type FieldType = 'text' | 'email' | 'number' | 'select' | 'textarea' | 'boolean'
@@ -186,6 +230,192 @@ export const SECTION_FIELDS: Record<keyof TemplateContext, FieldDef[]> = {
{ key: 'EDITORIAL_RESPONSIBLE_ADDRESS', label: 'V.i.S.d.P. Adresse' },
{ key: 'HAS_DISPUTE_RESOLUTION', label: 'Streitbeilegungshinweis', type: 'boolean' },
{ key: 'DISPUTE_RESOLUTION_TEXT', label: 'Streitbeilegungstext', type: 'textarea', span: true },
// ── SaaS AGB v2 ─────────────────────────────────────────────────────────
{ key: 'B2B_ONLY', label: 'Nur B2B (keine Verbraucher)', type: 'boolean' },
{ key: 'HAS_END_USERS', label: 'Endkunden-Weitergabe (B2B2C)', type: 'boolean' },
{ key: 'HAS_MODULAR_PACKAGES', label: 'Modulare Leistungspakete', type: 'boolean' },
{ key: 'HAS_STORAGE', label: 'Speicherplatz als Leistung', type: 'boolean' },
{ key: 'HAS_STORAGE_LIMITS', label: 'Speicherplatz begrenzt', type: 'boolean' },
{ key: 'HAS_TRIAL', label: 'Kostenlose Testphase', type: 'boolean' },
{ key: 'TRIAL_DAYS', label: 'Testphase (Tage)', type: 'select', opts: ['7', '14', '30'] },
{ key: 'HAS_PRICE_ADJUSTMENT', label: 'Preisanpassungsklausel', type: 'boolean' },
{ key: 'PRICE_ADJUSTMENT_NOTICE_WEEKS', label: 'Ankündigung Preisanpassung (Wo.)', type: 'select', opts: ['4', '8', '12'] },
{ key: 'PRICE_INCREASE_THRESHOLD_PERCENT', label: 'Schwelle Sonderkündigung (%)', type: 'select', opts: ['5', '10', '15'] },
{ key: 'HAS_UPLOAD', label: 'Datei-Upload Funktion', type: 'boolean' },
{ key: 'NO_AUDIT_PROOF_STORAGE', label: 'Keine revisionssichere Speicherung', type: 'boolean' },
{ key: 'HAS_API_ACCESS', label: 'API-Zugang', type: 'boolean' },
{ key: 'HAS_MAINTENANCE_ACCESS', label: 'Fernwartungszugang (On-Premise)', type: 'boolean' },
{ key: 'HAS_MAX_DOWNTIME', label: 'Max. Ausfalldauer begrenzt', type: 'boolean' },
{ key: 'MAX_DOWNTIME_DAYS', label: 'Max. Ausfalldauer (Tage)', type: 'number' },
{ key: 'HAS_IP_INDEMNIFICATION', label: 'IP-Freistellung (Schutzrechte)', type: 'boolean' },
{ key: 'LIABILITY_MULTIPLIER', label: 'Haftungsdeckel (x Jahreslizenz)', type: 'select', opts: ['1', '2', '3'] },
{ key: 'HAS_REFERENCE_MARKETING', label: 'Referenzmarketing (Logo-Nutzung)', type: 'boolean' },
{ key: 'HAS_WHITELABEL', label: 'Whitelabel-Paket vorhanden', type: 'boolean' },
{ key: 'HAS_FORCE_MAJEURE', label: 'Force-Majeure-Klausel', type: 'boolean' },
{ key: 'HAS_COMMUNITY_GUIDELINES', label: 'Community Guidelines als Bestandteil', type: 'boolean' },
// ── Community Guidelines (modular) ──────────────────────────────────────
{ key: 'TONE_FRIENDLY', label: 'Ton: Freundlich/Einladend', type: 'boolean' },
{ key: 'TONE_EDITORIAL', label: 'Ton: Editorial/Sachlich', type: 'boolean' },
{ key: 'TONE_FORMAL', label: 'Ton: Formal/Juristisch', type: 'boolean' },
{ key: 'HAS_MEDIA_UPLOADS', label: 'Plattform: Medien-Uploads (Bilder/Videos)', type: 'boolean' },
{ key: 'HAS_MESSAGING', label: 'Plattform: Messaging/Chat', type: 'boolean' },
{ key: 'HAS_MARKETPLACE', label: 'Plattform: Marketplace/Handel', type: 'boolean' },
{ key: 'DETAILED_ILLEGAL', label: '↳ Details: Rechtswidrige Inhalte', type: 'boolean' },
{ key: 'DETAILED_HATE_SPEECH', label: '↳ Details: Hassrede', type: 'boolean' },
{ key: 'DETAILED_FRAUD', label: '↳ Details: Betrug/Deepfakes', type: 'boolean' },
{ key: 'EXCEPTIONS_FRAUD', label: '↳ Ausnahmen: Parodie/Satire/Kunst', type: 'boolean' },
{ key: 'DETAILED_PRIVACY', label: '↳ Details: Sicherheit/Privatsphäre', type: 'boolean' },
{ key: 'DETAILED_VIOLENCE', label: '↳ Details: Gewalt (bei Medien-Uploads)', type: 'boolean' },
{ key: 'EXCEPTIONS_VIOLENCE', label: '↳ Ausnahmen: Kampfsport/Journalismus/Kunst', type: 'boolean' },
{ key: 'DETAILED_PORNOGRAPHY', label: '↳ Details: Pornografie (bei Medien-Uploads)', type: 'boolean' },
{ key: 'EXCEPTIONS_PORNOGRAPHY', label: '↳ Ausnahmen: Bodypainting/Stillen/Medizin', type: 'boolean' },
{ key: 'DETAILED_SELF_HARM', label: '↳ Details: Suizid/Selbstverletzung', type: 'boolean' },
{ key: 'EXCEPTIONS_SELF_HARM', label: '↳ Ausnahmen: Prävention/Journalismus', type: 'boolean' },
{ key: 'DETAILED_EXPLOITATION', label: '↳ Details: Ausbeutung/Missbrauch/CSAM', type: 'boolean' },
{ key: 'DETAILED_HARASSMENT', label: '↳ Details: Sexuelle Belästigung (bei Messaging)', type: 'boolean' },
{ key: 'DETAILED_DANGEROUS_PRODUCTS', label: '↳ Details: Gefährliche Produkte (bei Marketplace)', type: 'boolean' },
{ key: 'DETAILED_TERRORISM', label: '↳ Details: Terrorismus/Gefährliche Gruppen', type: 'boolean' },
{ key: 'DETAILED_DANGEROUS_ACTIVITIES', label: '↳ Details: Gefährdende Aktivitäten', type: 'boolean' },
{ key: 'GUIDELINES_URL', label: 'URL der Richtlinien' },
// ── Medien & Content Module ─────────────────────────────────────────────
{ key: 'IS_JOURNALISTIC_MEDIA', label: 'Journalistisches Medium (MStV §§ 18-22)', type: 'boolean' },
{ key: 'EDITORIAL_EMAIL', label: 'Redaktions-E-Mail (Gegendarstellung)', type: 'email' },
{ key: 'HAS_AI_GENERATED_CONTENT', label: 'KI-generierte Inhalte (AI Act Art. 50)', type: 'boolean' },
{ key: 'DETAILED_AI_LABELING', label: '↳ Detaillierte KI-Kennzeichnungstabelle', type: 'boolean' },
{ key: 'HAS_SPONSORED_CONTENT', label: 'Bezahlte/werbliche Inhalte (§ 5a UWG)', type: 'boolean' },
{ key: 'HAS_PRESS_COUNCIL', label: 'Pressekodex-Selbstverpflichtung (Presserat)', type: 'boolean' },
// ── Nutzungsbedingungen ─────────────────────────────────────────────────
{ key: 'HAS_UGC', label: 'User Generated Content', type: 'boolean' },
{ key: 'HAS_CONTENT_LICENSING', label: 'Content Licensing (Nutzer-zu-Nutzer)', type: 'boolean' },
{ key: 'HAS_TDM_OPTOUT', label: 'Text- und Data-Mining Opt-out', type: 'boolean' },
{ key: 'HAS_CONTENT_AUTHENTICITY', label: 'Content Authenticity (kryptogr. Herkunft)', type: 'boolean' },
{ key: 'HAS_TIPPING', label: 'Tipping/Anerkennungsfunktion', type: 'boolean' },
{ key: 'HAS_CRYPTO_PAYMENTS', label: 'Krypto-Zahlungen', type: 'boolean' },
{ key: 'HAS_INTEGRATED_WALLET', label: 'Integriertes Wallet (Non-Custodial)', type: 'boolean' },
{ key: 'HAS_IDENTITY_VERIFICATION', label: 'Identitätsprüfung erforderlich', type: 'boolean' },
{ key: 'HAS_COPYRIGHT_TAKEDOWN', label: 'Copyright Takedown-Verfahren', type: 'boolean' },
{ key: 'HAS_PAID_USER_ACCOUNTS', label: 'Kostenpflichtige Nutzeraccounts', type: 'boolean' },
{ key: 'HAS_EU_USERS', label: 'EU-weite Nutzer (Verbraucherschutz)', type: 'boolean' },
{ key: 'MFA_REQUIRED', label: 'MFA verpflichtend für Nutzer', type: 'boolean' },
{ key: 'DATA_EXPORT_BEFORE_DELETION', label: 'Datenexport vor Kontolöschung', type: 'boolean' },
{ key: 'EXPORT_BEFORE_DELETION_DAYS', label: 'Exportfrist (Tage)', type: 'select', opts: ['7', '14', '30'] },
{ key: 'MIN_AGE', label: 'Mindestalter', type: 'select', opts: ['13', '16', '18'] },
{ key: 'ALLOWS_MINORS', label: 'Minderjährige mit Eltern-Einwilligung', type: 'boolean' },
{ key: 'TIPPING_FEE_PERCENT', label: 'Tipping-Gebühr (%)', type: 'number' },
{ key: 'SUPPORTED_CURRENCIES', label: 'Unterstützte Währungen/Token' },
// ── Widerrufsbelehrung ──────────────────────────────────────────────────
{ key: 'HAS_PHYSICAL_GOODS', label: 'Physische Waren (Rücksendung)', type: 'boolean' },
{ key: 'HAS_COMBO_PACKAGE', label: 'Kombi-Paket (Hardware + Software)', type: 'boolean' },
{ key: 'HAS_DIGITAL_CONTENT', label: 'Digitale Inhalte (§ 356 Abs. 5 BGB)', type: 'boolean' },
{ key: 'HAS_SAAS_SERVICE', label: 'SaaS-Dienstleistung (§ 356 Abs. 4 BGB)', type: 'boolean' },
{ key: 'HAS_IOT_BUNDLE', label: 'Verbundenes Produkt (Hardware + App/Cloud)', type: 'boolean' },
{ key: 'IOT_SEPARATE_CONTRACTS', label: '↳ HW und Cloud getrennt widerrufbar', type: 'boolean' },
{ key: 'RETURN_ADDRESS', label: 'Rücksendeadresse (Servicecenter)' },
// ── Social Media DSI ────────────────────────────────────────────────────
{ key: 'HAS_FACEBOOK', label: 'Facebook & Instagram', type: 'boolean' },
{ key: 'HAS_YOUTUBE', label: 'YouTube', type: 'boolean' },
{ key: 'HAS_LINKEDIN', label: 'LinkedIn', type: 'boolean' },
{ key: 'HAS_TIKTOK', label: 'TikTok', type: 'boolean' },
{ key: 'HAS_X_TWITTER', label: 'X (Twitter)', type: 'boolean' },
{ key: 'HAS_META_PIXEL', label: 'Meta Pixel (Konversionsmessung)', type: 'boolean' },
{ key: 'HAS_RECRUITING_VIA_SOCIAL', label: 'Personalgewinnung über Social Media', type: 'boolean' },
{ key: 'SOCIAL_MEDIA_PLATFORMS_LIST', label: 'Plattform-Liste (Text)', type: 'textarea', span: true },
// ── DSI Erweiterungen ───────────────────────────────────────────────────
{ key: 'DSI_TITLE', label: 'Titel', type: 'select', opts: ['Datenschutzerklaerung', 'Datenschutzinformation'] },
{ key: 'SERVICE_SCOPE_DESCRIPTION', label: 'Geltungsbereich (z.B. "die App xy" / "den Online-Shop")' },
{ key: 'HAS_ONLINE_SHOP', label: 'Online-Shop Funktionen', type: 'boolean' },
{ key: 'HAS_PICKUP_STATION', label: 'Abholstationen', type: 'boolean' },
{ key: 'HAS_SUBSCRIPTION', label: 'Abonnement-Modell', type: 'boolean' },
{ key: 'HAS_PRODUCT_REVIEWS', label: 'Produktbewertungen', type: 'boolean' },
{ key: 'HAS_PARENT_COMPANY', label: 'Konzernstruktur (Mutter-/Tochtergesellschaft)', type: 'boolean' },
{ key: 'HAS_LOCATION', label: 'Standortdaten erhoben', type: 'boolean' },
{ key: 'HAS_E2E_ENCRYPTION', label: 'Ende-zu-Ende-Verschlüsselung (Messaging)', type: 'boolean' },
{ key: 'DETAILED_RIGHTS', label: 'Ausführliche Rechte-Beschreibung', type: 'boolean' },
{ key: 'PROCESSOR_LIST_URL', label: 'URL Auftragsverarbeiter-Liste' },
// ── Whistleblower ───────────────────────────────────────────────────────
{ key: 'WHISTLEBLOWER_CONTACT_NAME', label: 'Meldestelle: Ansprechperson' },
{ key: 'WHISTLEBLOWER_CONTACT_ROLE', label: 'Meldestelle: Funktion/Rolle' },
{ key: 'WHISTLEBLOWER_EMAIL', label: 'Meldestelle: E-Mail', type: 'email' },
{ key: 'WHISTLEBLOWER_PHONE', label: 'Meldestelle: Telefon' },
{ key: 'WHISTLEBLOWER_URL', label: 'Meldestelle: Online-Formular URL' },
{ key: 'HAS_ANONYMOUS_REPORTING', label: 'Anonyme Meldungen möglich', type: 'boolean' },
{ key: 'HAS_EXTERNAL_REPORTING', label: 'Externe Meldestelle (BfJ) erwähnen', type: 'boolean' },
// ── Bewerber-DSI ────────────────────────────────────────────────────────
{ key: 'HAS_VIDEO_INTERVIEW', label: 'Video-Interviews', type: 'boolean' },
{ key: 'HAS_ASSESSMENT', label: 'Assessment-Center/Tests', type: 'boolean' },
{ key: 'HAS_TALENT_POOL', label: 'Talentpool (Einwilligung)', type: 'boolean' },
{ key: 'TALENT_POOL_MONTHS', label: 'Talentpool Aufbewahrung (Monate)', type: 'select', opts: ['6', '12', '24'] },
{ key: 'HAS_RECRUITING_AGENCY', label: 'Personalvermittler', type: 'boolean' },
{ key: 'HAS_RECRUITING_SOFTWARE', label: 'Bewerbermanagement-Software', type: 'boolean' },
{ key: 'HAS_EMPLOYEE_REFERRAL', label: 'Mitarbeiterempfehlungen', type: 'boolean' },
// ── Mitarbeiter-DSI ─────────────────────────────────────────────────────
{ key: 'HAS_IT_USAGE_MONITORING', label: 'IT-Nutzungsüberwachung', type: 'boolean' },
{ key: 'HAS_COMPANY_VEHICLE', label: 'Dienstfahrzeuge/Fuhrpark', type: 'boolean' },
{ key: 'HAS_ACCESS_CONTROL', label: 'Zutrittskontrolle (Chipkarte)', type: 'boolean' },
{ key: 'HAS_VIDEO_SURVEILLANCE', label: 'Videoüberwachung (Arbeitsplatz)', type: 'boolean' },
{ key: 'HAS_COMPANY_PENSION', label: 'Betriebliche Altersvorsorge', type: 'boolean' },
{ key: 'HAS_EXTERNAL_HR_SOFTWARE', label: 'Externe HR-Software', type: 'boolean' },
{ key: 'HAS_WORKS_COUNCIL', label: 'Betriebsrat vorhanden', type: 'boolean' },
{ key: 'HAS_SPECIAL_CATEGORIES_EMPLOYEES', label: 'Besondere Datenkategorien (Gesundheit, Religion)', type: 'boolean' },
],
// ── TOM ─────────────────────────────────────────────────────────────────
TOM: [
{ key: 'ISB_NAME', label: 'IT-Sicherheitsbeauftragter' },
{ key: 'GF_NAME', label: 'Geschäftsführung' },
{ key: 'DOCUMENT_VERSION', label: 'Dokumentversion' },
{ key: 'NEXT_REVIEW_DATE', label: 'Nächste Prüfung (JJJJ-MM-TT)' },
{ key: 'HAS_MFA', label: 'Multi-Faktor-Authentifizierung aktiv', type: 'boolean' },
{ key: 'HAS_USB_LOCKED', label: 'USB-Schnittstellen physisch gesperrt', type: 'boolean' },
{ key: 'HAS_MOBILE_MEDIA', label: 'Mobile Datenträger im Einsatz', type: 'boolean' },
{ key: 'HAS_FOUR_EYES_DELETE', label: 'Vier-Augen-Prinzip für Löschungen', type: 'boolean' },
{ key: 'LOG_RETENTION_MONTHS', label: 'Log-Aufbewahrung (Monate)', type: 'select', opts: ['3', '6', '12', '24'] },
{ key: 'DIN_66399_LEVEL', label: 'Vernichtungsstufe (DIN 66399)', type: 'select', opts: ['1', '2', '3', '4', '5', '6', '7'] },
{ key: 'HAS_EXTERNAL_DESTRUCTION', label: 'Externer Vernichtungsdienstleister', type: 'boolean' },
{ key: 'HAS_PHYSICAL_TRANSPORT', label: 'Physischer Datenträgertransport', type: 'boolean' },
{ key: 'HAS_THIRD_COUNTRY_TRANSFER', label: 'Datenübermittlung in Drittländer', type: 'boolean' },
{ key: 'AVAILABILITY_TARGET', label: 'Verfügbarkeitsziel', type: 'select', opts: ['99.0', '99.5', '99.9', '99.99'] },
{ key: 'HAS_USV', label: 'USV vorhanden', type: 'boolean' },
{ key: 'HAS_REDUNDANCY', label: 'Redundante Systeme / Failover', type: 'boolean' },
{ key: 'HAS_GEO_REDUNDANCY', label: 'Georedundanter Standort', type: 'boolean' },
{ key: 'HAS_OWN_SERVER_ROOM', label: 'Eigener Serverraum', type: 'boolean' },
{ key: 'HAS_CLOUD_SERVICES', label: 'Cloud-Dienste im Einsatz', type: 'boolean' },
{ key: 'HAS_MULTI_TENANT', label: 'Multi-Tenant-System', type: 'boolean' },
{ key: 'SEPARATION_TYPE', label: 'Art der Mandantentrennung', type: 'select', opts: ['logisch', 'physisch', 'eigene Infrastruktur'] },
{ key: 'HAS_TEST_DATA_ANONYMIZED', label: 'Testdaten anonymisiert/synthetisch', type: 'boolean' },
],
// ── DPA / AVV ─────────────────────────────────────────────────────────
DPA: [
{ key: 'AG_NAME', label: 'Auftraggeber (Name/Firma)' },
{ key: 'AG_STRASSE', label: 'Auftraggeber Straße' },
{ key: 'AG_PLZ_ORT', label: 'Auftraggeber PLZ Ort' },
{ key: 'AN_NAME', label: 'Auftragnehmer (Name/Firma)' },
{ key: 'AN_STRASSE', label: 'Auftragnehmer Straße' },
{ key: 'AN_PLZ_ORT', label: 'Auftragnehmer PLZ Ort' },
{ key: 'VERARBEITUNGSGEGENSTAND', label: 'Gegenstand der Verarbeitung', type: 'textarea', span: true },
{ key: 'VERARBEITUNGSZWECK', label: 'Zweck der Verarbeitung', type: 'textarea', span: true },
{ key: 'VERARBEITUNGSARTEN', label: 'Art der Verarbeitung (Erheben, Speichern, …)', type: 'textarea', span: true },
{ key: 'DATENKATEGORIEN', label: 'Datenkategorien', type: 'textarea', span: true },
{ key: 'PERSONENKATEGORIEN', label: 'Betroffene Personenkategorien', type: 'textarea', span: true },
{ key: 'BREACH_NOTIFICATION_HOURS', label: 'Meldefrist Datenschutzverletzung (h)', type: 'select', opts: ['12', '24', '48'] },
{ key: 'INSTRUCTION_RETENTION_YEARS', label: 'Aufbewahrung Weisungen (Jahre)', type: 'select', opts: ['3', '5', '10'] },
{ key: 'SUB_PROCESSOR_NOTICE_WEEKS', label: 'Ankündigung Sub-AV (Wochen)', type: 'select', opts: ['2', '4', '6'] },
{ key: 'SUB_PROCESSOR_OBJECTION_WEEKS', label: 'Widerspruchsfrist Sub-AV (Wochen)', type: 'select', opts: ['2', '4'] },
{ key: 'DATA_EXPORT_FORMAT', label: 'Datenformat bei Rückgabe', type: 'select', opts: ['CSV/JSON', 'CSV', 'JSON', 'XML', 'nach Vereinbarung'] },
{ key: 'RETURN_CHOICE_WEEKS', label: 'Frist Rückgabe-Wahl (Wochen)', type: 'select', opts: ['2', '4', '8'] },
{ key: 'DELETION_DAYS', label: 'Löschfrist nach Vertragsende (Tage)', type: 'select', opts: ['30', '60', '90'] },
{ key: 'AN_DSB_NAME', label: 'DSB Auftragnehmer Name' },
{ key: 'AN_DSB_EMAIL', label: 'DSB Auftragnehmer E-Mail', type: 'email' },
{ key: 'VERTRAGSDATUM', label: 'Vertragsdatum (JJJJ-MM-TT)' },
{ key: 'GERICHTSSTAND', label: 'Gerichtsstand' },
{ key: 'HAS_LIABILITY_PROTECTION', label: 'Haftungsschutz bei Weisung (§ 4.1a)', type: 'boolean' },
{ key: 'HAS_SUPPORT_COST_CLAUSE', label: 'Kostenregelung Unterstützung (§ 7.4)', type: 'boolean' },
{ key: 'HAS_SUB_PROCESSOR_SILENCE_APPROVAL', label: 'Zustimmungsfiktion bei Sub-AV (§ 8.2a)', type: 'boolean' },
{ key: 'HAS_SUB_PROCESSOR_TERMINATION_RIGHT', label: 'Kündigungsrecht bei Sub-AV-Widerspruch (§ 8.3)', type: 'boolean' },
{ key: 'HAS_REACTIVATION_PERIOD', label: 'Reaktivierungszeitraum (§ 10.1)', type: 'boolean' },
{ key: 'REACTIVATION_MONTHS', label: 'Reaktivierung (Monate)', type: 'select', opts: ['1', '3', '6'] },
{ key: 'HAS_RETURN_COST_CLAUSE', label: 'Kosten für Datenrückgabe (§ 10.5)', type: 'boolean' },
{ key: 'HAS_GERICHTSSTAND_CLAUSE', label: 'Gerichtsstandklausel (§ 11.1)', type: 'boolean' },
{ key: 'HAS_UNILATERAL_CHANGE_RIGHT', label: '⚠️ Einseitiges Änderungsrecht AN (§ 11.6)', type: 'boolean' },
],
}
@@ -9,6 +9,8 @@ import type {
TemplateContext,
ProviderCtx,
ComputedFlags,
TOMCtx,
DPACtx,
} from './contextBridge'
// =============================================================================
@@ -44,6 +46,8 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
const con = ctx.CONSENT
const h = ctx.HOSTING
const f = ctx.FEATURES
const tom = ctx.TOM
const dpa = ctx.DPA
const address = providerAddress(p)
@@ -180,6 +184,86 @@ export function contextToPlaceholders(ctx: TemplateContext): Record<string, stri
'{{LIMITATION_CAP_TEXT}}': str(f.LIMITATION_CAP_TEXT),
'{{CONSUMER_WITHDRAWAL_TEXT}}': str(f.CONSUMER_WITHDRAWAL_TEXT),
'{{SUPPORT_CHANNELS_TEXT}}': str(f.SUPPORT_CHANNELS_TEXT),
// --- TOM ---
'{{ISB_NAME}}': str(tom.ISB_NAME),
'{{GF_NAME}}': str(tom.GF_NAME),
'{{DOCUMENT_VERSION}}': str(tom.DOCUMENT_VERSION),
'{{NEXT_REVIEW_DATE}}': str(tom.NEXT_REVIEW_DATE),
// --- DPA / AVV ---
'{{AG_NAME}}': str(dpa.AG_NAME) || str(c.LEGAL_NAME),
'{{AG_STRASSE}}': str(dpa.AG_STRASSE) || str(c.ADDRESS_LINE),
'{{AG_PLZ_ORT}}': str(dpa.AG_PLZ_ORT) || [c.POSTAL_CODE, c.CITY].filter(Boolean).join(' '),
'{{AN_NAME}}': str(dpa.AN_NAME) || str(p.LEGAL_NAME),
'{{AN_STRASSE}}': str(dpa.AN_STRASSE) || str(p.ADDRESS_LINE),
'{{AN_PLZ_ORT}}': str(dpa.AN_PLZ_ORT) || [p.POSTAL_CODE, p.CITY].filter(Boolean).join(' '),
'{{VERARBEITUNGSGEGENSTAND}}': str(dpa.VERARBEITUNGSGEGENSTAND),
'{{VERARBEITUNGSZWECK}}': str(dpa.VERARBEITUNGSZWECK),
'{{VERARBEITUNGSARTEN}}': str(dpa.VERARBEITUNGSARTEN),
'{{DATENKATEGORIEN}}': str(dpa.DATENKATEGORIEN),
'{{PERSONENKATEGORIEN}}': str(dpa.PERSONENKATEGORIEN),
'{{BREACH_NOTIFICATION_HOURS}}': str(dpa.BREACH_NOTIFICATION_HOURS) || str(sec.INCIDENT_NOTICE_HOURS),
'{{INSTRUCTION_RETENTION_YEARS}}': str(dpa.INSTRUCTION_RETENTION_YEARS),
'{{SUB_PROCESSOR_NOTICE_WEEKS}}': str(dpa.SUB_PROCESSOR_NOTICE_WEEKS),
'{{SUB_PROCESSOR_OBJECTION_WEEKS}}': str(dpa.SUB_PROCESSOR_OBJECTION_WEEKS),
'{{DATA_EXPORT_FORMAT}}': str(dpa.DATA_EXPORT_FORMAT),
'{{RETURN_CHOICE_WEEKS}}': str(dpa.RETURN_CHOICE_WEEKS),
'{{DELETION_DAYS}}': str(dpa.DELETION_DAYS),
'{{REACTIVATION_MONTHS}}': str(dpa.REACTIVATION_MONTHS),
'{{TERMINATION_WEEKS}}': str(dpa.TERMINATION_WEEKS),
'{{CHANGE_NOTICE_WEEKS}}': str(dpa.CHANGE_NOTICE_WEEKS),
'{{THIRD_COUNTRY_OBJECTION_WEEKS}}': str(dpa.THIRD_COUNTRY_OBJECTION_WEEKS),
'{{AN_DSB_NAME}}': str(dpa.AN_DSB_NAME) || str(prv.DPO_NAME),
'{{AN_DSB_EMAIL}}': str(dpa.AN_DSB_EMAIL) || str(prv.DPO_EMAIL),
'{{AG_ORT}}': str(dpa.AG_ORT),
'{{AN_ORT}}': str(dpa.AN_ORT),
'{{VERTRAGSDATUM}}': str(dpa.VERTRAGSDATUM) || str(l.VERSION_DATE),
'{{AG_UNTERZEICHNER_NAME}}': str(dpa.AG_UNTERZEICHNER_NAME),
'{{AG_UNTERZEICHNER_FUNKTION}}': str(dpa.AG_UNTERZEICHNER_FUNKTION),
'{{AN_UNTERZEICHNER_NAME}}': str(dpa.AN_UNTERZEICHNER_NAME) || str(p.CEO_NAME),
'{{AN_UNTERZEICHNER_FUNKTION}}': str(dpa.AN_UNTERZEICHNER_FUNKTION),
'{{GERICHTSSTAND}}': str(dpa.GERICHTSSTAND) || str(l.JURISDICTION_CITY),
// --- FEATURES: Whistleblower ---
'{{WHISTLEBLOWER_CONTACT_NAME}}': str(f.WHISTLEBLOWER_CONTACT_NAME),
'{{WHISTLEBLOWER_CONTACT_ROLE}}': str(f.WHISTLEBLOWER_CONTACT_ROLE),
'{{WHISTLEBLOWER_EMAIL}}': str(f.WHISTLEBLOWER_EMAIL),
'{{WHISTLEBLOWER_PHONE}}': str(f.WHISTLEBLOWER_PHONE),
'{{WHISTLEBLOWER_URL}}': str(f.WHISTLEBLOWER_URL),
// --- FEATURES: Video Conference ---
'{{VIDEO_PROVIDER_NAME}}': str(f.VIDEO_PROVIDER_NAME),
'{{VIDEO_PROVIDER_COUNTRY}}': str(f.VIDEO_PROVIDER_COUNTRY),
'{{VIDEO_PROVIDER_ROLE}}': str(f.VIDEO_PROVIDER_ROLE),
'{{VIDEO_PROVIDER_PRIVACY_URL}}': str(f.VIDEO_PROVIDER_PRIVACY_URL),
'{{RECORDING_RETENTION_DAYS}}': str(f.RECORDING_RETENTION_DAYS),
// --- FEATURES: KI/AI ---
'{{APPROVED_AI_SYSTEMS}}': str(f.APPROVED_AI_SYSTEMS),
// --- FEATURES: BYOD ---
'{{BYOD_COST_DETAILS}}': str(f.BYOD_COST_DETAILS),
// --- FEATURES: Consent ---
'{{NEWSLETTER_SIGNUP_URL}}': str(f.NEWSLETTER_SIGNUP_URL),
// --- FEATURES: Social Media ---
'{{SOCIAL_MEDIA_PLATFORMS_LIST}}': str(f.SOCIAL_MEDIA_PLATFORMS_LIST),
'{{EDITORIAL_EMAIL}}': str(f.EDITORIAL_EMAIL),
// --- FEATURES: Transfer/SCC ---
'{{RECIPIENT_NAME}}': str(f.RECIPIENT_NAME),
'{{RECIPIENT_COUNTRY}}': str(f.RECIPIENT_COUNTRY),
'{{RECIPIENT_ADDRESS}}': str(f.RECIPIENT_ADDRESS),
'{{RECIPIENT_CONTACT}}': str(f.RECIPIENT_CONTACT),
'{{RECIPIENT_EMAIL}}': str(f.RECIPIENT_EMAIL),
'{{RECIPIENT_ROLE}}': str(f.RECIPIENT_ROLE),
'{{TRANSFER_PURPOSE}}': str(f.TRANSFER_PURPOSE),
'{{TRANSFER_MECHANISM}}': str(f.TRANSFER_MECHANISM),
'{{DATA_CATEGORIES_TRANSFERRED}}': str(f.DATA_CATEGORIES_TRANSFERRED),
'{{DATA_SUBJECTS}}': str(f.DATA_SUBJECTS),
'{{TRANSFER_FREQUENCY}}': str(f.TRANSFER_FREQUENCY),
// --- FEATURES: DSI ---
'{{DSI_TITLE}}': str(f.DSI_TITLE) || 'Datenschutzerklaerung',
'{{SERVICE_SCOPE_DESCRIPTION}}': str(f.SERVICE_SCOPE_DESCRIPTION),
'{{FULFILLMENT_LOCATION}}': str(f.FULFILLMENT_LOCATION),
'{{GUIDELINES_URL}}': str(f.GUIDELINES_URL),
'{{PROCESSOR_LIST_URL}}': str(f.PROCESSOR_LIST_URL),
}
}
@@ -216,7 +300,9 @@ const SECTION_COVERS: Record<keyof TemplateContext, string[]> = {
NDA: ['{{PURPOSE}}', '{{DURATION_YEARS}}', '{{PENALTY_AMOUNT}}'],
CONSENT: ['{{WEBSITE_NAME}}', '{{ANALYTICS_TOOLS}}', '{{MARKETING_PARTNERS}}', '{{ANALYTICS_TOOLS_LIST}}', '{{MARKETING_PARTNERS_LIST}}'],
HOSTING: ['{{HOSTING_PROVIDER_NAME}}', '{{HOSTING_PROVIDER_COUNTRY}}', '{{HOSTING_PROVIDER_CONTRACT_TYPE}}'],
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}'],
FEATURES: ['{{CONSENT_WITHDRAWAL_PATH}}', '{{SECURITY_MEASURES_SUMMARY}}', '{{DATA_SUBJECT_REQUEST_CHANNEL}}', '{{TRANSFER_GUARDS}}', '{{REGULATED_PROFESSION_TEXT}}', '{{EDITORIAL_RESPONSIBLE_NAME}}', '{{EDITORIAL_RESPONSIBLE_ADDRESS}}', '{{DISPUTE_RESOLUTION_TEXT}}', '{{NEWSLETTER_PROVIDER_DETAIL}}', '{{PAYMENT_PROVIDER_DETAIL}}', '{{SOCIAL_MEDIA_DETAIL}}', '{{ANALYTICS_TOOLS_DETAIL}}', '{{MARKETING_TOOLS_DETAIL}}', '{{CMP_NAME}}', '{{PRICES_TEXT}}', '{{PAYMENT_TERMS_TEXT}}', '{{CONTRACT_TERM_TEXT}}', '{{SLA_URL}}', '{{EXPORT_POLICY_TEXT}}', '{{LIMITATION_CAP_TEXT}}', '{{CONSUMER_WITHDRAWAL_TEXT}}', '{{SUPPORT_CHANNELS_TEXT}}', '{{WHISTLEBLOWER_CONTACT_NAME}}', '{{WHISTLEBLOWER_EMAIL}}', '{{WHISTLEBLOWER_URL}}', '{{VIDEO_PROVIDER_NAME}}', '{{APPROVED_AI_SYSTEMS}}', '{{SOCIAL_MEDIA_PLATFORMS_LIST}}', '{{DSI_TITLE}}', '{{SERVICE_SCOPE_DESCRIPTION}}', '{{GUIDELINES_URL}}', '{{PROCESSOR_LIST_URL}}', '{{RECIPIENT_NAME}}', '{{RECIPIENT_COUNTRY}}', '{{TRANSFER_PURPOSE}}', '{{TRANSFER_MECHANISM}}'],
TOM: ['{{ISB_NAME}}', '{{GF_NAME}}', '{{DOCUMENT_VERSION}}', '{{NEXT_REVIEW_DATE}}'],
DPA: ['{{AG_NAME}}', '{{AG_STRASSE}}', '{{AG_PLZ_ORT}}', '{{AN_NAME}}', '{{AN_STRASSE}}', '{{AN_PLZ_ORT}}', '{{VERARBEITUNGSGEGENSTAND}}', '{{VERARBEITUNGSZWECK}}', '{{VERARBEITUNGSARTEN}}', '{{DATENKATEGORIEN}}', '{{PERSONENKATEGORIEN}}', '{{BREACH_NOTIFICATION_HOURS}}', '{{INSTRUCTION_RETENTION_YEARS}}', '{{SUB_PROCESSOR_NOTICE_WEEKS}}', '{{SUB_PROCESSOR_OBJECTION_WEEKS}}', '{{DATA_EXPORT_FORMAT}}', '{{RETURN_CHOICE_WEEKS}}', '{{DELETION_DAYS}}', '{{REACTIVATION_MONTHS}}', '{{TERMINATION_WEEKS}}', '{{AN_DSB_NAME}}', '{{AN_DSB_EMAIL}}', '{{AG_ORT}}', '{{AN_ORT}}', '{{VERTRAGSDATUM}}', '{{AG_UNTERZEICHNER_NAME}}', '{{AG_UNTERZEICHNER_FUNKTION}}', '{{AN_UNTERZEICHNER_NAME}}', '{{AN_UNTERZEICHNER_FUNKTION}}', '{{GERICHTSSTAND}}'],
}
/**
@@ -167,6 +167,84 @@ export interface FeaturesCtx {
SUPPORT_CHANNELS_TEXT: string
}
export interface TOMCtx {
ISB_NAME: string
GF_NAME: string
DOCUMENT_VERSION: string
NEXT_REVIEW_DATE: string
// Conditional blocks
HAS_PHYSICAL_TRANSPORT: boolean
HAS_THIRD_COUNTRY_TRANSFER: boolean
HAS_CLOUD_SERVICES: boolean
HAS_MFA: boolean
HAS_USB_LOCKED: boolean
HAS_MOBILE_MEDIA: boolean
HAS_FOUR_EYES_DELETE: boolean
HAS_EXTERNAL_DESTRUCTION: boolean
HAS_REDUNDANCY: boolean
HAS_GEO_REDUNDANCY: boolean
HAS_USV: boolean
HAS_OWN_SERVER_ROOM: boolean
HAS_MULTI_TENANT: boolean
HAS_TEST_DATA_ANONYMIZED: boolean
// Selects
LOG_RETENTION_MONTHS: number | ''
DIN_66399_LEVEL: string
AVAILABILITY_TARGET: string
SEPARATION_TYPE: string
}
export interface DPACtx {
// Parties
AG_NAME: string
AG_STRASSE: string
AG_PLZ_ORT: string
AN_NAME: string
AN_STRASSE: string
AN_PLZ_ORT: string
// Processing details
VERARBEITUNGSGEGENSTAND: string
VERARBEITUNGSZWECK: string
VERARBEITUNGSARTEN: string
DATENKATEGORIEN: string
PERSONENKATEGORIEN: string
// Timings
BREACH_NOTIFICATION_HOURS: number | ''
INSTRUCTION_RETENTION_YEARS: number | ''
SUB_PROCESSOR_NOTICE_WEEKS: number | ''
SUB_PROCESSOR_OBJECTION_WEEKS: number | ''
RETURN_CHOICE_WEEKS: number | ''
DELETION_DAYS: number | ''
REACTIVATION_MONTHS: number | ''
TERMINATION_WEEKS: number | ''
CHANGE_NOTICE_WEEKS: number | ''
THIRD_COUNTRY_OBJECTION_WEEKS: number | ''
// Data return
DATA_EXPORT_FORMAT: string
// DSB
AN_DSB_NAME: string
AN_DSB_EMAIL: string
// Signatures
AG_ORT: string
AN_ORT: string
VERTRAGSDATUM: string
AG_UNTERZEICHNER_NAME: string
AG_UNTERZEICHNER_FUNKTION: string
AN_UNTERZEICHNER_NAME: string
AN_UNTERZEICHNER_FUNKTION: string
GERICHTSSTAND: string
// Optional clauses
HAS_LIABILITY_PROTECTION: boolean
HAS_SUPPORT_COST_CLAUSE: boolean
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: boolean
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: boolean
HAS_REACTIVATION_PERIOD: boolean
HAS_RETURN_COST_CLAUSE: boolean
HAS_GERICHTSSTAND_CLAUSE: boolean
HAS_UNILATERAL_CHANGE_RIGHT: boolean
HAS_THIRD_COUNTRY_OBJECTION: boolean
}
export interface TemplateContext {
PROVIDER: ProviderCtx
CUSTOMER: CustomerCtx
@@ -180,6 +258,8 @@ export interface TemplateContext {
CONSENT: ConsentCtx
HOSTING: HostingCtx
FEATURES: FeaturesCtx
TOM: TOMCtx
DPA: DPACtx
}
export interface ComputedFlags {
@@ -263,6 +343,37 @@ export const EMPTY_CONTEXT: TemplateContext = {
LIMITATION_CAP_TEXT: '', HAS_WITHDRAWAL: false, CONSUMER_WITHDRAWAL_TEXT: '',
SUPPORT_CHANNELS_TEXT: '',
},
TOM: {
ISB_NAME: '', GF_NAME: '', DOCUMENT_VERSION: '1.0.0', NEXT_REVIEW_DATE: '',
HAS_PHYSICAL_TRANSPORT: false, HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: false, HAS_MFA: true, HAS_USB_LOCKED: false,
HAS_MOBILE_MEDIA: false, HAS_FOUR_EYES_DELETE: false,
HAS_EXTERNAL_DESTRUCTION: false, HAS_REDUNDANCY: false,
HAS_GEO_REDUNDANCY: false, HAS_USV: true, HAS_OWN_SERVER_ROOM: false,
HAS_MULTI_TENANT: false, HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 6, DIN_66399_LEVEL: '3',
AVAILABILITY_TARGET: '99.5', SEPARATION_TYPE: 'logisch',
},
DPA: {
AG_NAME: '', AG_STRASSE: '', AG_PLZ_ORT: '',
AN_NAME: '', AN_STRASSE: '', AN_PLZ_ORT: '',
VERARBEITUNGSGEGENSTAND: '', VERARBEITUNGSZWECK: '', VERARBEITUNGSARTEN: '',
DATENKATEGORIEN: '', PERSONENKATEGORIEN: '',
BREACH_NOTIFICATION_HOURS: 24, INSTRUCTION_RETENTION_YEARS: 3,
SUB_PROCESSOR_NOTICE_WEEKS: 2, SUB_PROCESSOR_OBJECTION_WEEKS: 2,
RETURN_CHOICE_WEEKS: 4, DELETION_DAYS: 90, REACTIVATION_MONTHS: 3,
TERMINATION_WEEKS: 4, CHANGE_NOTICE_WEEKS: 4, THIRD_COUNTRY_OBJECTION_WEEKS: 3,
DATA_EXPORT_FORMAT: 'CSV/JSON', AN_DSB_NAME: '', AN_DSB_EMAIL: '',
AG_ORT: '', AN_ORT: '', VERTRAGSDATUM: '',
AG_UNTERZEICHNER_NAME: '', AG_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
AN_UNTERZEICHNER_NAME: '', AN_UNTERZEICHNER_FUNKTION: 'Geschaeftsfuehrer',
GERICHTSSTAND: '',
HAS_LIABILITY_PROTECTION: false, HAS_SUPPORT_COST_CLAUSE: false,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true, HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
HAS_REACTIVATION_PERIOD: true, HAS_RETURN_COST_CLAUSE: false,
HAS_GERICHTSSTAND_CLAUSE: true, HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
}
// =============================================================================
@@ -0,0 +1,14 @@
{
"document_type": "ai_usage_policy",
"language": "de",
"context": {
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
"FEATURES": {
"APPROVED_AI_SYSTEMS": "ChatGPT (OpenAI), GitHub Copilot, DeepL Pro",
"HAS_APPROVED_AI_LIST": true,
"HAS_AI_LABELING_INTERNAL": true,
"HAS_TDM_OPTOUT": true
},
"TOM": { "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2026-11-01" }
}
}
@@ -0,0 +1,36 @@
{
"document_type": "dpa",
"language": "de",
"context": {
"DPA": {
"AG_NAME": "Muster GmbH",
"AG_STRASSE": "Musterstrasse 1",
"AG_PLZ_ORT": "10115 Berlin",
"AN_NAME": "BreakPilot GmbH",
"AN_STRASSE": "Hardtring 6",
"AN_PLZ_ORT": "78224 Singen",
"VERARBEITUNGSGEGENSTAND": "Bereitstellung und Betrieb einer SaaS-Compliance-Plattform",
"VERARBEITUNGSZWECK": "Compliance-Management, Dokumentengenerierung, Risikobewertung",
"VERARBEITUNGSARTEN": "Erheben, Speichern, Veraendern, Auslesen, Abfragen, Uebermitteln, Loeschen",
"DATENKATEGORIEN": "Stammdaten, Kontaktdaten, Vertragsdaten, Nutzungsdaten, Kommunikationsdaten",
"PERSONENKATEGORIEN": "Mitarbeitende des Auftraggebers, Kunden des Auftraggebers, Ansprechpartner",
"BREACH_NOTIFICATION_HOURS": 24,
"INSTRUCTION_RETENTION_YEARS": 3,
"SUB_PROCESSOR_NOTICE_WEEKS": 4,
"SUB_PROCESSOR_OBJECTION_WEEKS": 2,
"DATA_EXPORT_FORMAT": "CSV/JSON",
"RETURN_CHOICE_WEEKS": 4,
"DELETION_DAYS": 90,
"AN_DSB_NAME": "Max Mustermann",
"AN_DSB_EMAIL": "datenschutz@breakpilot.ai",
"VERTRAGSDATUM": "2026-05-01",
"AG_ORT": "Berlin",
"AN_ORT": "Singen",
"AG_UNTERZEICHNER_NAME": "Anna Beispiel",
"AG_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrerin",
"AN_UNTERZEICHNER_NAME": "Benjamin Boenisch",
"AN_UNTERZEICHNER_FUNKTION": "Geschaeftsfuehrer",
"GERICHTSSTAND": "Singen"
}
}
}
@@ -0,0 +1,33 @@
{
"document_type": "employee_dsi",
"language": "de",
"context": {
"PROVIDER": {
"LEGAL_NAME": "Muster GmbH",
"LEGAL_FORM": "GmbH",
"ADDRESS_LINE": "Musterstrasse 1",
"POSTAL_CODE": "10115",
"CITY": "Berlin",
"COUNTRY": "DE",
"EMAIL": "info@muster.de",
"PHONE": "+49 30 123456"
},
"PRIVACY": {
"DPO_NAME": "Dr. Datenschutz",
"DPO_EMAIL": "dsb@muster.de",
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz"
},
"FEATURES": {
"HAS_IT_USAGE_MONITORING": true,
"HAS_COMPANY_VEHICLE": false,
"HAS_ACCESS_CONTROL": true,
"HAS_VIDEO_SURVEILLANCE": false,
"HAS_COMPANY_PENSION": true,
"HAS_EXTERNAL_HR_SOFTWARE": true,
"HAS_WORKS_COUNCIL": false,
"HAS_SPECIAL_CATEGORIES_EMPLOYEES": true,
"DATA_SUBJECT_REQUEST_CHANNEL": "per E-Mail an dsb@muster.de"
},
"SECURITY": { "LOG_RETENTION_DAYS": 90 }
}
}
@@ -0,0 +1,27 @@
{
"document_type": "social_media_dsi",
"language": "de",
"context": {
"PROVIDER": {
"LEGAL_NAME": "Muster GmbH",
"WEBSITE_URL": "https://www.muster.de",
"EMAIL": "info@muster.de",
"PHONE": "+49 30 123456"
},
"PRIVACY": {
"DPO_EMAIL": "dsb@muster.de",
"SUPERVISORY_AUTHORITY_NAME": "Berliner Beauftragte fuer Datenschutz",
"SUPERVISORY_AUTHORITY_ADDRESS": "Friedrichstr. 219, 10969 Berlin"
},
"FEATURES": {
"HAS_FACEBOOK": true,
"HAS_YOUTUBE": true,
"HAS_LINKEDIN": true,
"HAS_TIKTOK": false,
"HAS_X_TWITTER": false,
"HAS_META_PIXEL": true,
"HAS_RECRUITING_VIA_SOCIAL": true,
"SOCIAL_MEDIA_PLATFORMS_LIST": "Facebook, Instagram, YouTube und LinkedIn"
}
}
}
@@ -0,0 +1,19 @@
{
"document_type": "transfer_impact_assessment",
"language": "de",
"context": {
"PROVIDER": { "LEGAL_NAME": "Muster GmbH" },
"PRIVACY": { "DPO_NAME": "Dr. Datenschutz", "DPO_EMAIL": "dsb@muster.de" },
"FEATURES": {
"RECIPIENT_NAME": "Cloud Provider Inc.",
"RECIPIENT_COUNTRY": "US",
"RECIPIENT_ROLE": "Auftragsverarbeiter",
"TRANSFER_PURPOSE": "Hosting der Anwendungsdaten",
"TRANSFER_MECHANISM": "EU-Standardvertragsklauseln (SCC) + EU-US DPF",
"DATA_CATEGORIES_TRANSFERRED": "Stammdaten, Kontaktdaten, Nutzungsdaten",
"DATA_SUBJECTS": "Kunden, Nutzer der Plattform",
"TRANSFER_FREQUENCY": "Kontinuierlich (Echtzeit-Datenverarbeitung)"
},
"TOM": { "GF_NAME": "Max Geschaeftsfuehrer", "DOCUMENT_VERSION": "1.0.0", "NEXT_REVIEW_DATE": "2027-05-01" }
}
}
@@ -0,0 +1,30 @@
{
"document_type": "tom_documentation",
"language": "de",
"context": {
"TOM": {
"ISB_NAME": "Thomas Sicher",
"GF_NAME": "Benjamin Boenisch",
"DOCUMENT_VERSION": "2.0.0",
"NEXT_REVIEW_DATE": "2027-05-01",
"HAS_MFA": true,
"HAS_USB_LOCKED": false,
"HAS_MOBILE_MEDIA": false,
"HAS_FOUR_EYES_DELETE": true,
"HAS_EXTERNAL_DESTRUCTION": true,
"HAS_PHYSICAL_TRANSPORT": false,
"HAS_THIRD_COUNTRY_TRANSFER": false,
"HAS_CLOUD_SERVICES": true,
"HAS_REDUNDANCY": true,
"HAS_GEO_REDUNDANCY": false,
"HAS_USV": true,
"HAS_OWN_SERVER_ROOM": true,
"HAS_MULTI_TENANT": true,
"HAS_TEST_DATA_ANONYMIZED": true,
"LOG_RETENTION_MONTHS": 12,
"DIN_66399_LEVEL": "4",
"AVAILABILITY_TARGET": "99.9",
"SEPARATION_TYPE": "logisch"
}
}
}
@@ -0,0 +1,18 @@
{
"document_type": "whistleblower_policy",
"language": "de",
"context": {
"PROVIDER": {
"LEGAL_NAME": "Muster GmbH"
},
"FEATURES": {
"WHISTLEBLOWER_CONTACT_NAME": "Dr. Maria Compliance",
"WHISTLEBLOWER_CONTACT_ROLE": "Compliance-Beauftragte / Meldestellenbeauftragte",
"WHISTLEBLOWER_EMAIL": "meldestelle@muster.de",
"WHISTLEBLOWER_PHONE": "+49 123 456789",
"WHISTLEBLOWER_URL": "https://muster.de/meldestelle",
"HAS_ANONYMOUS_REPORTING": true,
"HAS_EXTERNAL_REPORTING": true
}
}
}
@@ -11,8 +11,10 @@ import { generateAllPlaceholders } from '@/lib/sdk/document-generator/datapoint-
import { loadAllTemplates } from './searchTemplates'
import { TemplateContext, EMPTY_CONTEXT } from './contextBridge'
import { CATEGORIES } from './_constants'
import { getGeneratorDefaults, getProfileLabel } from './scopeDefaults'
import TemplateLibrary from './_components/TemplateLibrary'
import GeneratorSection from './_components/GeneratorSection'
import RecommendedDocuments from './_components/RecommendedDocuments'
function DocumentGeneratorPageInner() {
const { state } = useSDK()
@@ -86,6 +88,91 @@ function DocumentGeneratorPageInner() {
}
}, [state?.companyProfile])
// Pre-fill TOM/DPA context from Compliance Scope Engine
useEffect(() => {
const scopeLevel = state?.complianceScope?.determinedLevel
if (scopeLevel) {
const defaults = getGeneratorDefaults(scopeLevel, state?.companyProfile as never)
setContext((prev) => ({
...prev,
TOM: { ...prev.TOM, ...defaults.tom },
DPA: { ...prev.DPA, ...defaults.dpa },
}))
}
}, [state?.complianceScope?.determinedLevel, state?.companyProfile])
// ── MODULE WIRING: CookieBanner → CONSENT + FEATURES ─────────────────────
useEffect(() => {
const banner = state?.cookieBanner
if (!banner) return
const cats = banner.categories || []
const analyticsTools = cats
.filter((c) => c.id === 'analytics' || c.id === 'statistics')
.flatMap((c) => c.cookies?.map((ck) => ck.name) ?? [])
const marketingTools = cats
.filter((c) => c.id === 'marketing')
.flatMap((c) => c.cookies?.map((ck) => ck.name) ?? [])
const hasFunctional = cats.some((c) => c.id === 'functional')
setContext((prev) => ({
...prev,
CONSENT: {
...prev.CONSENT,
ANALYTICS_TOOLS: analyticsTools.length > 0 ? analyticsTools.join(', ') : prev.CONSENT.ANALYTICS_TOOLS,
MARKETING_PARTNERS: marketingTools.length > 0 ? marketingTools.join(', ') : prev.CONSENT.MARKETING_PARTNERS,
},
FEATURES: {
...prev.FEATURES,
CMP_NAME: 'BreakPilot CMP',
CMP_LOGS_CONSENTS: true,
HAS_FUNCTIONAL_COOKIES: hasFunctional || prev.FEATURES.HAS_FUNCTIONAL_COOKIES,
CONSENT_WITHDRAWAL_PATH: 'Footer-Link "Cookie-Einstellungen"',
},
}))
}, [state?.cookieBanner])
// ── MODULE WIRING: Loeschfristen → PRIVACY retention ──────────────────────
useEffect(() => {
const policies = state?.retentionPolicies
if (!policies || policies.length === 0) return
const maxMonths = policies.reduce((max, p) => {
const match = p.retentionPeriod?.match(/(\d+)\s*(Monat|Jahr|Tag)/i)
if (!match) return max
const val = parseInt(match[1], 10)
const unit = match[2].toLowerCase()
const months = unit.startsWith('jahr') ? val * 12 : unit.startsWith('tag') ? Math.ceil(val / 30) : val
return Math.max(max, months)
}, 0)
if (maxMonths > 0) {
setContext((prev) => ({
...prev,
PRIVACY: { ...prev.PRIVACY, ANALYTICS_RETENTION_MONTHS: maxMonths },
}))
}
}, [state?.retentionPolicies])
// ── MODULE WIRING: UseCases → FEATURES flags ─────────────────────────────
useEffect(() => {
const useCases = state?.useCases
if (!useCases || useCases.length === 0) return
const allText = useCases.map((uc) => `${uc.name} ${uc.description}`).join(' ').toLowerCase()
const hasAccount = allText.includes('account') || allText.includes('konto') || allText.includes('registrier')
const hasPayments = allText.includes('zahlung') || allText.includes('payment') || allText.includes('stripe') || allText.includes('paypal')
const hasNewsletter = allText.includes('newsletter') || allText.includes('mailchimp') || allText.includes('e-mail-marketing')
const hasSocial = allText.includes('social') || allText.includes('linkedin') || allText.includes('facebook') || allText.includes('instagram')
setContext((prev) => ({
...prev,
FEATURES: {
...prev.FEATURES,
HAS_ACCOUNT: hasAccount || prev.FEATURES.HAS_ACCOUNT,
HAS_PAYMENTS: hasPayments || prev.FEATURES.HAS_PAYMENTS,
HAS_NEWSLETTER: hasNewsletter || prev.FEATURES.HAS_NEWSLETTER,
HAS_SOCIAL_MEDIA: hasSocial || prev.FEATURES.HAS_SOCIAL_MEDIA,
},
}))
}, [state?.useCases])
// Pre-fill extra placeholders from Einwilligungen data points
useEffect(() => {
if (selectedDataPointsData && selectedDataPointsData.length > 0) {
@@ -177,6 +264,12 @@ function DocumentGeneratorPageInner() {
</div>
</div>
{/* Recommended documents based on scope profile */}
<RecommendedDocuments
allTemplates={allTemplates}
onUseTemplate={handleUseTemplate}
/>
<TemplateLibrary
allTemplates={allTemplates}
filteredTemplates={filteredTemplates}
@@ -0,0 +1,320 @@
/**
* Scope-basierte Generator-Defaults
*
* Nimmt ScopeDecision.determinedLevel + CompanyProfile und liefert
* vorausgefuellte TOM/DPA-Context-Werte. Alle Felder bleiben vom
* Kunden aenderbar die Defaults sind Empfehlungen.
*
* Mapping:
* L1 = Lean Startup (10 MA, Cloud-only, Home Office)
* L2 = KMU Standard (11-249 MA)
* L3 = Erweitert (risikoreich oder >100 MA)
* L4 = Zertifizierungsbereit (250 MA oder regulierte Branche)
*/
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
import type { CompanyProfile } from '../../lib/sdk/types'
import type { TOMCtx, DPACtx } from './contextBridge'
// ============================================================================
// TOM Defaults per Level
// ============================================================================
const TOM_DEFAULTS: Record<ComplianceDepthLevel, Partial<TOMCtx>> = {
L1: {
// Lean Startup: Cloud-only, kein eigener Serverraum, Home Office
HAS_MFA: true,
HAS_USB_LOCKED: false,
HAS_MOBILE_MEDIA: false,
HAS_FOUR_EYES_DELETE: false,
HAS_EXTERNAL_DESTRUCTION: false,
HAS_PHYSICAL_TRANSPORT: false,
HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: true,
HAS_REDUNDANCY: false,
HAS_GEO_REDUNDANCY: false,
HAS_USV: false,
HAS_OWN_SERVER_ROOM: false,
HAS_MULTI_TENANT: false,
HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 3,
DIN_66399_LEVEL: '3',
AVAILABILITY_TARGET: '99.0',
SEPARATION_TYPE: 'logisch',
},
L2: {
// KMU Standard
HAS_MFA: true,
HAS_USB_LOCKED: false,
HAS_MOBILE_MEDIA: false,
HAS_FOUR_EYES_DELETE: false,
HAS_EXTERNAL_DESTRUCTION: false,
HAS_PHYSICAL_TRANSPORT: false,
HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: true,
HAS_REDUNDANCY: false,
HAS_GEO_REDUNDANCY: false,
HAS_USV: false,
HAS_OWN_SERVER_ROOM: false,
HAS_MULTI_TENANT: false,
HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 6,
DIN_66399_LEVEL: '3',
AVAILABILITY_TARGET: '99.5',
SEPARATION_TYPE: 'logisch',
},
L3: {
// Erweitert
HAS_MFA: true,
HAS_USB_LOCKED: false,
HAS_MOBILE_MEDIA: false,
HAS_FOUR_EYES_DELETE: true,
HAS_EXTERNAL_DESTRUCTION: true,
HAS_PHYSICAL_TRANSPORT: false,
HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: true,
HAS_REDUNDANCY: true,
HAS_GEO_REDUNDANCY: false,
HAS_USV: true,
HAS_OWN_SERVER_ROOM: true,
HAS_MULTI_TENANT: true,
HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 12,
DIN_66399_LEVEL: '4',
AVAILABILITY_TARGET: '99.9',
SEPARATION_TYPE: 'logisch',
},
L4: {
// Zertifizierungsbereit / Enterprise
HAS_MFA: true,
HAS_USB_LOCKED: true,
HAS_MOBILE_MEDIA: false,
HAS_FOUR_EYES_DELETE: true,
HAS_EXTERNAL_DESTRUCTION: true,
HAS_PHYSICAL_TRANSPORT: false,
HAS_THIRD_COUNTRY_TRANSFER: false,
HAS_CLOUD_SERVICES: true,
HAS_REDUNDANCY: true,
HAS_GEO_REDUNDANCY: true,
HAS_USV: true,
HAS_OWN_SERVER_ROOM: true,
HAS_MULTI_TENANT: true,
HAS_TEST_DATA_ANONYMIZED: true,
LOG_RETENTION_MONTHS: 24,
DIN_66399_LEVEL: '5',
AVAILABILITY_TARGET: '99.99',
SEPARATION_TYPE: 'logisch',
},
}
// ============================================================================
// DPA Defaults per Level
// ============================================================================
const DPA_DEFAULTS: Record<ComplianceDepthLevel, Partial<DPACtx>> = {
L1: {
BREACH_NOTIFICATION_HOURS: 48,
INSTRUCTION_RETENTION_YEARS: 3,
SUB_PROCESSOR_NOTICE_WEEKS: 2,
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
DATA_EXPORT_FORMAT: 'CSV/JSON',
RETURN_CHOICE_WEEKS: 4,
DELETION_DAYS: 90,
HAS_LIABILITY_PROTECTION: false,
HAS_SUPPORT_COST_CLAUSE: false,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
HAS_REACTIVATION_PERIOD: true,
REACTIVATION_MONTHS: 3,
HAS_RETURN_COST_CLAUSE: false,
HAS_GERICHTSSTAND_CLAUSE: false,
HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
L2: {
BREACH_NOTIFICATION_HOURS: 24,
INSTRUCTION_RETENTION_YEARS: 3,
SUB_PROCESSOR_NOTICE_WEEKS: 4,
SUB_PROCESSOR_OBJECTION_WEEKS: 2,
DATA_EXPORT_FORMAT: 'CSV/JSON',
RETURN_CHOICE_WEEKS: 4,
DELETION_DAYS: 90,
HAS_LIABILITY_PROTECTION: false,
HAS_SUPPORT_COST_CLAUSE: false,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: false,
HAS_REACTIVATION_PERIOD: true,
REACTIVATION_MONTHS: 3,
HAS_RETURN_COST_CLAUSE: false,
HAS_GERICHTSSTAND_CLAUSE: true,
HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
L3: {
BREACH_NOTIFICATION_HOURS: 24,
INSTRUCTION_RETENTION_YEARS: 5,
SUB_PROCESSOR_NOTICE_WEEKS: 4,
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
DATA_EXPORT_FORMAT: 'CSV/JSON',
RETURN_CHOICE_WEEKS: 4,
DELETION_DAYS: 60,
HAS_LIABILITY_PROTECTION: true,
HAS_SUPPORT_COST_CLAUSE: true,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: true,
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
HAS_REACTIVATION_PERIOD: true,
REACTIVATION_MONTHS: 3,
HAS_RETURN_COST_CLAUSE: true,
HAS_GERICHTSSTAND_CLAUSE: true,
HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
L4: {
BREACH_NOTIFICATION_HOURS: 12,
INSTRUCTION_RETENTION_YEARS: 5,
SUB_PROCESSOR_NOTICE_WEEKS: 6,
SUB_PROCESSOR_OBJECTION_WEEKS: 4,
DATA_EXPORT_FORMAT: 'CSV/JSON',
RETURN_CHOICE_WEEKS: 8,
DELETION_DAYS: 30,
HAS_LIABILITY_PROTECTION: true,
HAS_SUPPORT_COST_CLAUSE: true,
HAS_SUB_PROCESSOR_SILENCE_APPROVAL: false,
HAS_SUB_PROCESSOR_TERMINATION_RIGHT: true,
HAS_REACTIVATION_PERIOD: false,
REACTIVATION_MONTHS: 3,
HAS_RETURN_COST_CLAUSE: true,
HAS_GERICHTSSTAND_CLAUSE: true,
HAS_UNILATERAL_CHANGE_RIGHT: false,
HAS_THIRD_COUNTRY_OBJECTION: false,
},
}
// ============================================================================
// Public API
// ============================================================================
export interface GeneratorDefaults {
tom: Partial<TOMCtx>
dpa: Partial<DPACtx>
/** Which fields were set by the scope engine (for UI highlighting) */
scopeSet: Set<string>
}
/**
* Berechnet Generator-Defaults basierend auf dem Compliance-Level
* und dem CompanyProfile. Alle Werte sind Vorschlaege der Kunde
* kann sie aendern.
*/
export function getGeneratorDefaults(
level: ComplianceDepthLevel,
profile?: CompanyProfile | null,
): GeneratorDefaults {
const tomBase = { ...TOM_DEFAULTS[level] }
const dpaBase = { ...DPA_DEFAULTS[level] }
const scopeSet = new Set<string>()
// CompanyProfile-Felder in TOM/DPA uebernehmen
if (profile) {
if (profile.company_name) {
dpaBase.AN_NAME = profile.company_name
scopeSet.add('DPA.AN_NAME')
}
if (profile.address) {
dpaBase.AN_STRASSE = profile.address
scopeSet.add('DPA.AN_STRASSE')
}
if (profile.city && profile.postal_code) {
dpaBase.AN_PLZ_ORT = `${profile.postal_code} ${profile.city}`
scopeSet.add('DPA.AN_PLZ_ORT')
}
if (profile.dpo_name) {
tomBase.ISB_NAME = tomBase.ISB_NAME || ''
dpaBase.AN_DSB_NAME = profile.dpo_name
scopeSet.add('DPA.AN_DSB_NAME')
}
if (profile.dpo_email) {
dpaBase.AN_DSB_EMAIL = profile.dpo_email
scopeSet.add('DPA.AN_DSB_EMAIL')
}
if (profile.ceo_name) {
dpaBase.AN_UNTERZEICHNER_NAME = profile.ceo_name
tomBase.GF_NAME = profile.ceo_name
scopeSet.add('DPA.AN_UNTERZEICHNER_NAME')
scopeSet.add('TOM.GF_NAME')
}
}
// Alle gesetzten TOM/DPA Felder als scope-set markieren
for (const key of Object.keys(tomBase)) {
scopeSet.add(`TOM.${key}`)
}
for (const key of Object.keys(dpaBase)) {
scopeSet.add(`DPA.${key}`)
}
return { tom: tomBase, dpa: dpaBase, scopeSet }
}
/**
* Gibt das empfohlene Profil-Label zurueck (fuer UI-Anzeige).
*/
export function getProfileLabel(level: ComplianceDepthLevel): string {
const labels: Record<ComplianceDepthLevel, string> = {
L1: 'Startup / Kleinstunternehmen',
L2: 'KMU Standard',
L3: 'Erweiterte Compliance',
L4: 'Zertifizierungsbereit / Enterprise',
}
return labels[level]
}
/**
* Empfiehlt relevante Dokumenttypen basierend auf dem Compliance-Level.
* Hilft dem Kunden zu verstehen, welche Dokumente er braucht.
*/
export function getRecommendedDocuments(level: ComplianceDepthLevel): {
required: string[]
recommended: string[]
optional: string[]
} {
const always = [
'privacy_policy', 'impressum', 'agb', 'cookie_banner', 'cookie_policy',
]
const l2plus = [
'dpa', 'tom_documentation', 'vvt_register', 'loeschkonzept',
'community_guidelines', 'terms_of_use',
]
const l3plus = [
'it_security_concept', 'data_protection_concept', 'incident_response_plan',
'access_control_concept', 'backup_recovery_concept', 'logging_concept',
'risk_management_concept', 'pflichtenregister',
'password_policy', 'encryption_policy', 'information_security_policy',
'access_control_policy', 'whistleblower_policy',
'employee_dsi', 'applicant_dsi', 'ai_usage_policy',
]
const l4only = [
'isms_manual', 'cybersecurity_policy', 'byod_policy',
'dsfa', 'social_media_dsi', 'media_content_policy',
'video_conference_dsi', 'consent_texts',
'data_protection_policy', 'data_classification_policy',
'data_retention_policy', 'data_transfer_policy',
'privacy_incident_policy', 'employee_security_policy',
'security_awareness_policy', 'remote_work_policy',
'offboarding_policy', 'vendor_risk_management_policy',
'third_party_security_policy', 'supplier_security_policy',
'business_continuity_policy', 'disaster_recovery_policy',
'crisis_management_policy',
]
switch (level) {
case 'L1':
return { required: always, recommended: [], optional: l2plus }
case 'L2':
return { required: always, recommended: l2plus, optional: l3plus }
case 'L3':
return { required: [...always, ...l2plus], recommended: l3plus, optional: l4only }
case 'L4':
return { required: [...always, ...l2plus, ...l3plus], recommended: l4only, optional: [] }
}
}
@@ -0,0 +1,326 @@
/**
* Template Recommendations Maps scope answers to document templates
*
* Bridges the gap between the Compliance Scope Engine (23 ScopeDocumentTypes)
* and the Document Generator (70+ database templates).
*
* The scope engine recommends high-level document categories (vvt, tom, dsfa...).
* This module recommends SPECIFIC templates based on additional context from
* the CompanyProfile and scope answers.
*/
import type { ComplianceDepthLevel } from '../../lib/sdk/compliance-scope-types/core-levels'
import type { ScopeProfilingAnswer } from '../../lib/sdk/compliance-scope-types/state'
// ============================================================================
// Template recommendation rules
// ============================================================================
interface TemplateRule {
/** Database document_type */
templateType: string
/** Human-readable label */
label: string
/** When to recommend this template */
condition: (answers: Map<string, string>, level: ComplianceDepthLevel, profile: Record<string, unknown>) => 'required' | 'recommended' | 'optional' | null
}
/**
* Rules that map scope answers + profile to specific template recommendations.
* These cover templates NOT directly output by the scope engine.
*/
const TEMPLATE_RULES: TemplateRule[] = [
// ── HR-DSI ──────────────────────────────────────────────────────────────
{
templateType: 'employee_dsi',
label: 'Mitarbeiter-Datenschutzinformation',
condition: (answers, level) => {
const hasEmployees = answers.get('org_has_employees')
const empCount = answers.get('org_employee_count')
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
return level >= 'L2' ? 'required' : 'recommended'
}
return null
},
},
{
templateType: 'applicant_dsi',
label: 'Bewerber-Datenschutzinformation',
condition: (answers, level) => {
const hasEmployees = answers.get('org_has_employees')
const empCount = answers.get('org_employee_count')
if (hasEmployees === 'yes' || (empCount && empCount !== 'none' && empCount !== '0')) {
return level >= 'L2' ? 'recommended' : 'optional'
}
return null
},
},
// ── Whistleblower ───────────────────────────────────────────────────────
{
templateType: 'whistleblower_policy',
label: 'Hinweisgeberrichtlinie (HinSchG)',
condition: (answers) => {
const empCount = answers.get('org_employee_count')
// HinSchG Pflicht ab 50 MA
if (empCount === '50_249' || empCount === '250_999' || empCount === '1000_plus') return 'required'
return null
},
},
// ── KI ──────────────────────────────────────────────────────────────────
{
templateType: 'ai_usage_policy',
label: 'KI-Nutzungsrichtlinie',
condition: (answers) => {
const aiUsage = answers.get('proc_ai_usage') || answers.get('proc_uses_ai_tools')
if (aiUsage && aiUsage !== 'none' && aiUsage !== 'no') return 'required'
return null
},
},
// ── BYOD ────────────────────────────────────────────────────────────────
{
templateType: 'byod_policy',
label: 'BYOD-Richtlinie',
condition: (answers, level) => {
const byod = answers.get('proc_byod_allowed')
if (byod === 'yes') return 'required'
if (level >= 'L3') return 'recommended'
return 'optional'
},
},
// ── Social Media ────────────────────────────────────────────────────────
{
templateType: 'social_media_dsi',
label: 'Social-Media-Datenschutzinformation',
condition: (answers, level) => {
const sm = answers.get('org_has_social_media')
if (sm === 'yes') return 'required'
return level >= 'L2' ? 'recommended' : 'optional'
},
},
// ── Videokonferenzen ────────────────────────────────────────────────────
{
templateType: 'video_conference_dsi',
label: 'Videokonferenz-Datenschutzinformation',
condition: (answers, level) => {
const video = answers.get('org_has_video_conferencing')
if (video === 'yes') return 'recommended'
if (level >= 'L3') return 'recommended'
return 'optional'
},
},
// ── Security Policies (nur ab L3/L4) ───────────────────────────────────
{
templateType: 'information_security_policy',
label: 'Informationssicherheitsrichtlinie',
condition: (_answers, level) => {
if (level >= 'L3') return 'required'
if (level === 'L2') return 'recommended'
return null
},
},
{
templateType: 'password_policy',
label: 'Passwortrichtlinie',
condition: (_answers, level) => level >= 'L2' ? 'recommended' : 'optional',
},
{
templateType: 'encryption_policy',
label: 'Verschluesselungsrichtlinie',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
{
templateType: 'access_control_policy',
label: 'Zugriffskontrollrichtlinie',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
// ── Security Concepts (nur ab L3) ──────────────────────────────────────
{
templateType: 'it_security_concept',
label: 'IT-Sicherheitskonzept',
condition: (_answers, level) => level >= 'L3' ? 'required' : 'optional',
},
{
templateType: 'backup_recovery_concept',
label: 'Backup-Recovery-Konzept',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
{
templateType: 'logging_concept',
label: 'Logging-Konzept',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
{
templateType: 'access_control_concept',
label: 'Zugriffskonzept',
condition: (_answers, level) => level >= 'L3' ? 'recommended' : 'optional',
},
// ── Plattform/UGC ──────────────────────────────────────────────────────
{
templateType: 'community_guidelines',
label: 'Gemeinschaftsrichtlinien',
condition: (answers) => {
const model = answers.get('org_business_model')
const ugc = answers.get('prod_ugc_platform')
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social') return 'required'
return null
},
},
{
templateType: 'terms_of_use',
label: 'Nutzungsbedingungen',
condition: (answers) => {
const model = answers.get('org_business_model')
const ugc = answers.get('prod_ugc_platform')
if (ugc === 'yes' || model === 'platform' || model === 'marketplace' || model === 'social' || model === 'saas') return 'required'
return null
},
},
{
templateType: 'media_content_policy',
label: 'Medien- und Inhalte-Richtlinie',
condition: (answers) => {
const model = answers.get('org_business_model')
if (model === 'platform' || model === 'media') return 'recommended'
return null
},
},
// ── E-Commerce ─────────────────────────────────────────────────────────
{
templateType: 'widerruf',
label: 'Widerrufsbelehrung',
condition: (answers) => {
const shop = answers.get('prod_webshop')
if (shop && shop !== 'no') return 'required'
return null
},
},
{
templateType: 'consent_texts',
label: 'Einwilligungstexte (Double-Opt-In)',
condition: (answers) => {
const consent = answers.get('prod_consent_management')
if (consent && consent !== 'no') return 'recommended'
return 'optional'
},
},
// ── Impressum + Cookie ─────────────────────────────────────────────────
{
templateType: 'impressum',
label: 'Impressum',
condition: () => 'required', // Immer Pflicht
},
{
templateType: 'cookie_policy',
label: 'Cookie-Richtlinie',
condition: () => 'required', // Immer Pflicht bei Websites
},
// ── Drittlandtransfer (SCC + TIA) ───────────────────────────────────────
// SCC+TIA nur erforderlich wenn Drittlandtransfer OHNE Angemessenheitsbeschluss/DPF
{
templateType: 'transfer_impact_assessment',
label: 'Transfer Impact Assessment (TIA)',
condition: (answers) => {
const thirdCountry = answers.get('tech_third_country')
if (!thirdCountry || thirdCountry === 'no') return null
// Wenn nur DPF-zertifizierte US-Anbieter: empfohlen statt pflicht
if (thirdCountry === 'us_dpf_only') return 'optional'
// Wenn nur Laender mit Angemessenheitsbeschluss: nicht noetig
if (thirdCountry === 'adequate_only') return null
return 'required'
},
},
{
templateType: 'scc_companion',
label: 'Standardvertragsklauseln (SCC) — Anhaenge',
condition: (answers) => {
const thirdCountry = answers.get('tech_third_country')
if (!thirdCountry || thirdCountry === 'no') return null
if (thirdCountry === 'us_dpf_only') return 'optional'
if (thirdCountry === 'adequate_only') return null
return 'required'
},
},
// ── ISMS (nur bei Zertifizierungsziel) ─────────────────────────────────
{
templateType: 'isms_manual',
label: 'ISMS-Handbuch',
condition: (answers) => {
const cert = answers.get('org_cert_target')
if (cert === 'iso27001' || cert === 'iso27701' || cert === 'tisax') return 'required'
return null
},
},
// ── Vendor/BCM (nur ab L4 oder bei Vendor-Management) ─────────────────
{
templateType: 'vendor_risk_management_policy',
label: 'Vendor-Risikomanagement',
condition: (answers, level) => {
const vendor = answers.get('comp_vendor_management')
if (vendor && vendor !== 'no') return 'recommended'
if (level === 'L4') return 'required'
return null
},
},
{
templateType: 'business_continuity_policy',
label: 'Business-Continuity-Richtlinie',
condition: (_answers, level) => level === 'L4' ? 'required' : 'optional',
},
]
// ============================================================================
// Public API
// ============================================================================
export interface TemplateRecommendation {
templateType: string
label: string
requirement: 'required' | 'recommended' | 'optional'
}
/**
* Evaluates all template rules against the user's scope answers and profile.
* Returns a prioritized list of template recommendations.
*/
export function evaluateTemplateRecommendations(
scopeAnswers: ScopeProfilingAnswer[],
level: ComplianceDepthLevel,
profile: Record<string, unknown> = {},
): TemplateRecommendation[] {
const answerMap = new Map<string, string>()
for (const a of scopeAnswers) {
answerMap.set(a.questionId, String(a.value))
}
const results: TemplateRecommendation[] = []
for (const rule of TEMPLATE_RULES) {
const requirement = rule.condition(answerMap, level, profile)
if (requirement) {
results.push({
templateType: rule.templateType,
label: rule.label,
requirement,
})
}
}
// Sort: required first, then recommended, then optional
const order = { required: 0, recommended: 1, optional: 2 }
results.sort((a, b) => order[a.requirement] - order[b.requirement])
return results
}
@@ -2,16 +2,38 @@
import React, { useState } from 'react'
import type { DSFA } from './DSFACard'
import type { DSFAPrefillResult } from '@/lib/sdk/dsfa/prefill-from-scope'
export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; onSubmit: (data: Partial<DSFA>) => Promise<void> }) {
interface GeneratorWizardProps {
onClose: () => void
onSubmit: (data: Partial<DSFA>) => Promise<void>
prefill?: DSFAPrefillResult | null
}
export function GeneratorWizard({ onClose, onSubmit, prefill }: GeneratorWizardProps) {
const [step, setStep] = useState(1)
const [saving, setSaving] = useState(false)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [processingActivity, setProcessingActivity] = useState('')
const [selectedCategories, setSelectedCategories] = useState<string[]>([])
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
const [selectedMeasures, setSelectedMeasures] = useState<string[]>([])
const [title, setTitle] = useState(prefill?.title || '')
const [description, setDescription] = useState(prefill?.description || '')
const [processingActivity, setProcessingActivity] = useState(prefill?.processingActivity || '')
const [selectedCategories, setSelectedCategories] = useState<string[]>(prefill?.dataCategories || [])
const riskMap2: Record<string, 'low' | 'medium' | 'high' | 'critical'> = { niedrig: 'low', mittel: 'medium', hoch: 'high', kritisch: 'critical' }
const [riskLevel, setRiskLevel] = useState<'low' | 'medium' | 'high' | 'critical'>(riskMap2[prefill?.riskLevel || ''] || 'low')
const [residualRisk, setResidualRisk] = useState<'low' | 'medium' | 'high' | 'critical'>('low')
const [selectedMeasures, setSelectedMeasures] = useState<string[]>(prefill?.measures || [])
const [linkedVvtId, setLinkedVvtId] = useState('')
const [vvtActivities, setVvtActivities] = useState<Array<{ id: string; name: string }>>([])
// Load VVT activities for linking
React.useEffect(() => {
fetch('/api/sdk/v1/compliance/vvt')
.then(r => r.ok ? r.json() : [])
.then(data => {
const items = Array.isArray(data) ? data : data.activities || []
setVvtActivities(items.map((a: any) => ({ id: a.id, name: a.name || a.processing_name || a.title || 'Unbenannt' })))
})
.catch(() => {})
}, [])
const riskMap: Record<string, 'low' | 'medium' | 'high' | 'critical'> = {
Niedrig: 'low', Mittel: 'medium', Hoch: 'high', Kritisch: 'critical',
@@ -28,7 +50,12 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
riskLevel,
measures: selectedMeasures,
status: 'draft',
})
...(prefill?.federalState ? { federal_state: prefill.federalState } : {}),
...(prefill?.involvesAi ? { involves_ai: true } : {}),
...(prefill?.legalBasis ? { legal_basis: prefill.legalBasis } : {}),
...(linkedVvtId ? { linked_vvt_id: linkedVvtId } : {}),
...(residualRisk !== 'low' ? { residual_risk_level: residualRisk } : {}),
} as Partial<DSFA>)
onClose()
} finally {
setSaving(false)
@@ -48,7 +75,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
{/* Progress Steps */}
<div className="flex items-center gap-2 mb-6">
{[1, 2, 3, 4].map(s => (
{[1, 2, 3, 4, 5].map(s => (
<React.Fragment key={s}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
s < step ? 'bg-green-500 text-white' :
@@ -60,7 +87,7 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
</svg>
) : s}
</div>
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
{s < 5 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
</React.Fragment>
))}
</div>
@@ -89,6 +116,20 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
/>
</div>
{vvtActivities.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verknuepfte VVT-Aktivitaet (Art. 30)</label>
<select value={linkedVvtId} onChange={e => {
setLinkedVvtId(e.target.value)
const selected = vvtActivities.find(a => a.id === e.target.value)
if (selected && !processingActivity) setProcessingActivity(selected.name)
}} className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 bg-white">
<option value=""> Keine Verknuepfung </option>
{vvtActivities.map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<p className="text-xs text-gray-400 mt-1">Ordnen Sie diese DSFA einer VVT-Verarbeitungstaetigkeit zu.</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungstaetigkeit</label>
<input
@@ -167,6 +208,43 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
</div>
</div>
)}
{step === 5 && (
<div className="space-y-4">
<label className="block text-sm font-medium text-gray-700 mb-2">Restrisiko nach Massnahmen</label>
<p className="text-xs text-gray-500 mb-3">
Bewerten Sie das verbleibende Risiko NACH Umsetzung der Schutzmassnahmen.
Bei hohem Restrisiko Art. 36 Vorabkonsultation der Aufsichtsbehoerde.
</p>
<div className="grid grid-cols-2 gap-2">
{[
{ value: 'low' as const, label: 'Niedrig', desc: 'Risiko ausreichend gemindert', color: 'border-green-300 bg-green-50' },
{ value: 'medium' as const, label: 'Mittel', desc: 'Akzeptables Restrisiko', color: 'border-yellow-300 bg-yellow-50' },
{ value: 'high' as const, label: 'Hoch', desc: 'Art. 36 Konsultation pruefen', color: 'border-orange-300 bg-orange-50' },
{ value: 'critical' as const, label: 'Kritisch', desc: 'Art. 36 Konsultation PFLICHT', color: 'border-red-300 bg-red-50' },
].map(r => (
<label key={r.value} className={`flex items-start gap-2 p-3 border-2 rounded-lg cursor-pointer ${
residualRisk === r.value ? r.color : 'border-gray-200 hover:border-gray-300'
}`}>
<input type="radio" name="residualRisk" value={r.value} checked={residualRisk === r.value}
onChange={() => setResidualRisk(r.value)} className="mt-0.5" />
<div>
<span className="text-sm font-medium">{r.label}</span>
<p className="text-xs text-gray-500">{r.desc}</p>
</div>
</label>
))}
</div>
{(residualRisk === 'high' || residualRisk === 'critical') && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="text-sm text-red-700 font-medium">Vorabkonsultation erforderlich (Art. 36 DSGVO)</p>
<p className="text-xs text-red-600 mt-1">
Bei hohem Restrisiko muss die Aufsichtsbehoerde VOR Beginn der Verarbeitung konsultiert werden.
</p>
</div>
)}
</div>
)}
</div>
{/* Navigation */}
@@ -179,11 +257,11 @@ export function GeneratorWizard({ onClose, onSubmit }: { onClose: () => void; on
{step === 1 ? 'Abbrechen' : 'Zurueck'}
</button>
<button
onClick={() => step < 4 ? setStep(step + 1) : handleSubmit()}
onClick={() => step < 5 ? setStep(step + 1) : handleSubmit()}
disabled={saving || (step === 1 && !title.trim())}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
{step === 4 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
{step === 5 ? (saving ? 'Wird erstellt...' : 'DSFA erstellen') : 'Weiter'}
</button>
</div>
</div>
+45 -1
View File
@@ -1,12 +1,13 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
import { DSFACard, type DSFA } from './_components/DSFACard'
import { GeneratorWizard } from './_components/GeneratorWizard'
import { prefillDSFAFromScope, isDSFARequired } from '@/lib/sdk/dsfa/prefill-from-scope'
export default function DSFAPage() {
const router = useRouter()
@@ -17,6 +18,17 @@ export default function DSFAPage() {
const [showGenerator, setShowGenerator] = useState(false)
const [filter, setFilter] = useState<string>('all')
// Pre-fill from Company Profile + Scope answers
const scopeAnswers = state.complianceScope?.answers || []
const prefill = useMemo(
() => prefillDSFAFromScope(state.companyProfile || null, scopeAnswers),
[state.companyProfile, scopeAnswers]
)
const dsfaCheck = useMemo(
() => isDSFARequired(scopeAnswers, state.companyProfile?.headquartersState),
[scopeAnswers, state.companyProfile?.headquartersState]
)
const loadDSFAs = useCallback(async () => {
setIsLoading(true)
setError(null)
@@ -120,10 +132,42 @@ export default function DSFAPage() {
)}
</StepHeader>
{/* DSFA Requirement Check */}
{dsfaCheck.required && dsfas.length === 0 && (
<div className="bg-red-50 border border-red-200 rounded-xl p-5">
<h3 className="font-semibold text-red-800">DSFA erforderlich (Art. 35 DSGVO)</h3>
<p className="text-sm text-red-700 mt-1">Basierend auf Ihrem Scope-Profiling wurde festgestellt:</p>
<ul className="mt-2 space-y-1">
{dsfaCheck.triggers.map(t => (
<li key={t} className="text-sm text-red-600 flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
{t}
</li>
))}
</ul>
{dsfaCheck.blacklistMatches.length > 0 && (
<div className="mt-3 pt-3 border-t border-red-200">
<p className="text-xs font-medium text-red-800 mb-1">
Blacklist {dsfaCheck.authority || 'Aufsichtsbehoerde'} (Art. 35 Abs. 4):
</p>
<ul className="space-y-1">
{dsfaCheck.blacklistMatches.map(m => (
<li key={m} className="text-xs text-red-600 flex items-center gap-2">
<span className="w-1 h-1 bg-red-400 rounded-full flex-shrink-0" />
{m}
</li>
))}
</ul>
</div>
)}
</div>
)}
{showGenerator && (
<GeneratorWizard
onClose={() => setShowGenerator(false)}
onSubmit={handleCreateDSFA}
prefill={prefill}
/>
)}
@@ -9,7 +9,8 @@ export function ActionButtons({
onExtendDeadline,
onComplete,
onReject,
onAssign
onAssign,
onRejectArt11,
}: {
request: DSRRequest
onVerifyIdentity: () => void
@@ -17,15 +18,31 @@ export function ActionButtons({
onComplete: () => void
onReject: () => void
onAssign: () => void
onRejectArt11?: () => void
}) {
const isTerminal = request.status === 'completed' || request.status === 'rejected' || request.status === 'cancelled'
if (isTerminal) {
return (
<div className="space-y-2">
<button className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm">
<button
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=pdf`, '_blank')}
className="w-full px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm"
>
PDF exportieren
</button>
<button
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=json`, '_blank')}
className="w-full px-4 py-2 text-purple-600 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors text-sm"
>
JSON exportieren (Art. 20)
</button>
<button
onClick={() => window.open(`/api/sdk/v1/dsr/${request.id}/export-user-data?format=csv`, '_blank')}
className="w-full px-4 py-2 text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors text-sm"
>
CSV exportieren
</button>
</div>
)
}
@@ -33,12 +50,23 @@ export function ActionButtons({
return (
<div className="space-y-2">
{!request.identityVerification.verified && (
<button
onClick={onVerifyIdentity}
className="w-full px-4 py-2 bg-yellow-500 text-white hover:bg-yellow-600 rounded-lg transition-colors text-sm font-medium"
>
Identitaet verifizieren
</button>
<>
<button
onClick={onVerifyIdentity}
className="w-full px-4 py-2 bg-yellow-500 text-white hover:bg-yellow-600 rounded-lg transition-colors text-sm font-medium"
>
Identitaet verifizieren
</button>
{onRejectArt11 && (
<button
onClick={onRejectArt11}
className="w-full px-4 py-2 text-gray-600 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-lg transition-colors text-sm"
title="Person kann anhand der gespeicherten Daten nicht identifiziert werden (Art. 11 DSGVO)"
>
Nicht identifizierbar (Art. 11)
</button>
)}
</>
)}
<button
@@ -0,0 +1,273 @@
'use client'
import { useState } from 'react'
import { useBannerConsents } from '../_hooks/useBannerConsents'
import { BannerConsentRecord, PAGE_SIZE } from '../_types'
function formatDate(iso: string | null): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function shortenFingerprint(fp: string): string {
return fp.length > 12 ? fp.slice(0, 12) + '...' : fp
}
function shortenUA(ua: string | null): string {
if (!ua) return '—'
const match = ua.match(/(Chrome|Safari|Firefox|Edge|Opera)\/[\d.]+/)
if (match) return match[0]
return ua.length > 30 ? ua.slice(0, 30) + '...' : ua
}
const categoryColors: Record<string, string> = {
essential: 'bg-gray-100 text-gray-700',
functional: 'bg-blue-100 text-blue-700',
analytics: 'bg-purple-100 text-purple-700',
marketing: 'bg-pink-100 text-pink-700',
}
const methodLabels: Record<string, string> = {
accept_all: 'Alle akzeptiert',
reject_all: 'Nur notwendige',
custom_selection: 'Individuelle Auswahl',
}
const methodColors: Record<string, string> = {
accept_all: 'bg-green-100 text-green-700',
reject_all: 'bg-red-100 text-red-700',
custom_selection: 'bg-yellow-100 text-yellow-700',
}
export default function BannerConsentsTab() {
const {
records, sites, selectedSite, changeSite,
stats, currentPage, setCurrentPage, totalRecords, loading,
} = useBannerConsents()
const [detail, setDetail] = useState<BannerConsentRecord | null>(null)
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
return (
<div className="space-y-6">
{/* Stats + Site Selector */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-500">
<span className="text-2xl font-bold text-gray-900">{totalRecords}</span> Consents
</div>
{stats && Object.keys(stats.category_acceptance).length > 0 && (
<div className="flex gap-2">
{Object.entries(stats.category_acceptance).map(([cat, data]) => (
<span key={cat} className={`text-xs px-2 py-1 rounded-full ${categoryColors[cat] || 'bg-gray-100 text-gray-600'}`}>
{cat}: {data.rate}%
</span>
))}
</div>
)}
</div>
{sites.length > 0 && (
<select
value={selectedSite}
onChange={e => changeSite(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm bg-white"
>
{sites.map(s => (
<option key={s.site_id} value={s.site_id}>
{s.site_name || s.site_id}
</option>
))}
</select>
)}
</div>
{/* Table */}
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-500">Device</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Kategorien</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Methode</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Erteilt am</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Ablauf</th>
<th className="text-left px-4 py-3 font-medium text-gray-500">Browser</th>
<th className="text-right px-4 py-3 font-medium text-gray-500">Aktion</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{loading && records.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-400">Laden...</td></tr>
) : records.length === 0 ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-400">Keine Consents vorhanden</td></tr>
) : (
records.map(record => (
<tr key={record.id} className="hover:bg-gray-50 transition-colors">
<td className="px-4 py-3 font-mono text-xs text-gray-600">{shortenFingerprint(record.device_fingerprint)}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{record.categories.length > 0 ? record.categories.map(cat => (
<span key={cat} className={`text-xs px-2 py-0.5 rounded-full ${categoryColors[cat] || 'bg-gray-100 text-gray-600'}`}>
{cat}
</span>
)) : <span className="text-xs text-gray-400"></span>}
</div>
</td>
<td className="px-4 py-3 text-xs">
{record.consent_method ? (
<span className={`px-2 py-0.5 rounded-full ${methodColors[record.consent_method] || 'bg-gray-100 text-gray-600'}`}>
{methodLabels[record.consent_method] || record.consent_method}
</span>
) : <span className="text-gray-400"></span>}
</td>
<td className="px-4 py-3 text-xs text-gray-600">{formatDate(record.created_at)}</td>
<td className="px-4 py-3 text-xs text-gray-600">{formatDate(record.expires_at)}</td>
<td className="px-4 py-3 text-xs text-gray-500">{shortenUA(record.user_agent)}</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setDetail(record)}
className="text-xs text-purple-600 hover:text-purple-800 font-medium"
>
Details
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
Seite {currentPage} von {totalPages} ({totalRecords} Einträge)
</span>
<div className="flex gap-1">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage <= 1}
className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-30"
>
Zurück
</button>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage >= totalPages}
className="px-3 py-1 text-xs border border-gray-300 rounded disabled:opacity-30"
>
Weiter
</button>
</div>
</div>
)}
{/* Detail Modal */}
{detail && (
<div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center p-4" onClick={() => setDetail(null)}>
<div className="bg-white rounded-2xl shadow-xl max-w-lg w-full p-6 max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">Consent Details</h3>
<button onClick={() => setDetail(null)} className="text-gray-400 hover:text-gray-600 text-xl">&times;</button>
</div>
<div className="space-y-3 text-sm">
<div className="flex justify-between"><span className="text-gray-500">ID</span><span className="font-mono text-xs">{detail.id}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Site</span><span>{detail.site_id}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Device</span><span className="font-mono text-xs">{detail.device_fingerprint}</span></div>
<div className="flex justify-between items-start">
<span className="text-gray-500">Kategorien</span>
<div className="flex flex-wrap gap-1 justify-end">
{detail.categories.map(cat => (
<span key={cat} className={`text-xs px-2 py-0.5 rounded-full ${categoryColors[cat] || 'bg-gray-100'}`}>{cat}</span>
))}
</div>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Methode</span>
<span>{detail.consent_method ? (
<span className={`text-xs px-2 py-0.5 rounded-full ${methodColors[detail.consent_method] || 'bg-gray-100'}`}>
{methodLabels[detail.consent_method] || detail.consent_method}
</span>
) : '—'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Verknüpft mit</span>
<span>{detail.linked_email || '— (anonym)'}</span>
</div>
<div className="flex justify-between"><span className="text-gray-500">Erteilt</span><span>{formatDate(detail.created_at)}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Ablauf</span><span>{formatDate(detail.expires_at)}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Aktualisiert</span><span>{formatDate(detail.updated_at)}</span></div>
<div className="flex justify-between"><span className="text-gray-500">Geltungsbereich</span><span>{detail.consent_scope || '—'}</span></div>
{detail.banner_version && (
<div className="flex justify-between"><span className="text-gray-500">Banner-Version</span><span>{detail.banner_version}</span></div>
)}
{/* Tracking-Kontext */}
<div className="border-t border-gray-100 pt-3">
<p className="text-xs font-semibold text-gray-700 mb-2">Tracking-Kontext</p>
{detail.page_url && <div className="flex justify-between"><span className="text-gray-500 text-xs">Seite</span><span className="text-xs text-gray-600 truncate max-w-[250px]">{detail.page_url}</span></div>}
{detail.referrer && <div className="flex justify-between mt-1"><span className="text-gray-500 text-xs">Referrer</span><span className="text-xs text-gray-600 truncate max-w-[250px]">{detail.referrer}</span></div>}
{detail.geo_country && <div className="flex justify-between mt-1"><span className="text-gray-500 text-xs">Land</span><span className="text-xs text-gray-600">{detail.geo_country}{detail.geo_region ? ` / ${detail.geo_region}` : ''}</span></div>}
</div>
{/* Device-Informationen */}
<div className="border-t border-gray-100 pt-3">
<p className="text-xs font-semibold text-gray-700 mb-2">Device</p>
<div className="grid grid-cols-2 gap-1 text-xs">
<span className="text-gray-500">Typ</span><span className="text-gray-600">{detail.device_type || '—'}</span>
<span className="text-gray-500">Browser</span><span className="text-gray-600">{detail.browser || shortenUA(detail.user_agent)}</span>
<span className="text-gray-500">OS</span><span className="text-gray-600">{detail.os || '—'}</span>
<span className="text-gray-500">Auflösung</span><span className="text-gray-600">{detail.screen_resolution || '—'}</span>
</div>
</div>
{/* Scripts & Cookies */}
{(detail.scripts_released?.length > 0 || detail.cookies_set?.length > 0) && (
<div className="border-t border-gray-100 pt-3">
<p className="text-xs font-semibold text-gray-700 mb-2">Scripts & Cookies</p>
{detail.scripts_released?.length > 0 && (
<div className="mb-2">
<span className="text-gray-500 text-xs">Freigegebene Scripts</span>
{detail.scripts_released.map((s, i) => (
<p key={i} className="text-xs text-gray-600 font-mono truncate">{s.src} <span className={`px-1 rounded ${categoryColors[s.category] || 'bg-gray-100'}`}>{s.category}</span></p>
))}
</div>
)}
{detail.scripts_blocked?.length > 0 && (
<div className="mb-2">
<span className="text-gray-500 text-xs">Blockierte Scripts</span>
{detail.scripts_blocked.map((s, i) => (
<p key={i} className="text-xs text-red-600 font-mono truncate">{s.src} <span className="px-1 rounded bg-red-100 text-red-700">{s.category}</span></p>
))}
</div>
)}
{detail.cookies_set?.length > 0 && (
<div>
<span className="text-gray-500 text-xs">Gesetzte Cookies</span>
{detail.cookies_set.map((c, i) => (
<p key={i} className="text-xs text-gray-600 font-mono">{c.name} <span className="text-gray-400">({c.domain})</span> <span className={`px-1 rounded ${categoryColors[c.category] || 'bg-gray-100'}`}>{c.category}</span></p>
))}
</div>
)}
</div>
)}
{/* Technische Details */}
<div className="border-t border-gray-100 pt-3">
<p className="text-xs font-semibold text-gray-700 mb-2">Technisch</p>
<div className="space-y-1">
<div><span className="text-gray-500 text-xs">User-Agent</span><p className="text-xs text-gray-600 font-mono break-all">{detail.user_agent || '—'}</p></div>
{detail.ip_hash && <div><span className="text-gray-500 text-xs">IP-Hash</span><p className="text-xs text-gray-600 font-mono">{detail.ip_hash}</p></div>}
{detail.session_id && <div><span className="text-gray-500 text-xs">Session</span><p className="text-xs text-gray-600 font-mono">{detail.session_id}</p></div>}
{detail.banner_config_hash && <div><span className="text-gray-500 text-xs">Config-Hash</span><p className="text-xs text-gray-600 font-mono">{detail.banner_config_hash}</p></div>}
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}
@@ -35,7 +35,7 @@ const EINWILLIGUNGEN_TABS = [
{
id: 'cookie-banner',
label: 'Cookie-Banner',
href: '/sdk/einwilligungen/cookie-banner',
href: '/sdk/cookie-banner',
icon: Cookie,
description: 'Cookie-Consent konfigurieren',
},
@@ -0,0 +1,73 @@
import { useState, useEffect, useCallback } from 'react'
import { BannerConsentRecord, BannerConsentStats, BannerSite, PAGE_SIZE } from '../_types'
const BANNER_API = '/api/sdk/v1/banner'
const TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const HEADERS = { 'x-tenant-id': TENANT_ID }
function fb(path: string) {
return fetch(`${BANNER_API}/${path}`, { headers: HEADERS })
.then(r => r.ok ? r.json() : null)
.catch(() => null)
}
export function useBannerConsents() {
const [records, setRecords] = useState<BannerConsentRecord[]>([])
const [sites, setSites] = useState<BannerSite[]>([])
const [selectedSite, setSelectedSite] = useState<string>('')
const [stats, setStats] = useState<BannerConsentStats | null>(null)
const [currentPage, setCurrentPage] = useState(1)
const [totalRecords, setTotalRecords] = useState(0)
const [loading, setLoading] = useState(true)
// Load sites on mount
useEffect(() => {
fb('admin/sites').then(data => {
const list = Array.isArray(data) ? data : []
setSites(list)
if (list.length > 0) {
setSelectedSite(list[0].site_id)
}
setLoading(false)
})
}, [])
// Load consents + stats when site or page changes
const loadData = useCallback(async () => {
if (!selectedSite) return
setLoading(true)
const offset = (currentPage - 1) * PAGE_SIZE
const [consentsData, statsData] = await Promise.all([
fb(`admin/consents?site_id=${selectedSite}&limit=${PAGE_SIZE}&offset=${offset}`),
fb(`admin/stats/${selectedSite}`),
])
if (consentsData) {
setRecords(consentsData.consents || [])
setTotalRecords(consentsData.total || 0)
}
setStats(statsData)
setLoading(false)
}, [selectedSite, currentPage])
useEffect(() => {
loadData()
}, [loadData])
const changeSite = (siteId: string) => {
setSelectedSite(siteId)
setCurrentPage(1)
}
return {
records,
sites,
selectedSite,
changeSite,
stats,
currentPage,
setCurrentPage,
totalRecords,
loading,
reload: loadData,
}
}
@@ -100,3 +100,48 @@ export function formatDate(date: Date | null): string {
}
export const PAGE_SIZE = 50
// Banner (Device-based) Consent
export interface BannerConsentRecord {
id: string
site_id: string
device_fingerprint: string
categories: string[]
vendors: string[]
ip_hash: string | null
user_agent: string | null
linked_email: string | null
consent_string: string | null
// Vendor-agnostische Felder (Migration 107)
consent_method: string | null
banner_version: number | null
banner_config_hash: string | null
geo_country: string | null
geo_region: string | null
consent_scope: string | null
page_url: string | null
referrer: string | null
device_type: string | null
browser: string | null
os: string | null
screen_resolution: string | null
session_id: string | null
// Script/Cookie-Tracking (Migration 108)
scripts_blocked: { src: string; category: string }[]
scripts_released: { src: string; category: string }[]
cookies_set: { name: string; domain: string; expiry_days: number; category: string }[]
expires_at: string | null
created_at: string | null
updated_at: string | null
}
export interface BannerConsentStats {
total_consents: number
category_acceptance: Record<string, { count: number; rate: number }>
}
export interface BannerSite {
site_id: string
site_name: string
site_url: string
}
@@ -130,7 +130,7 @@ function CatalogContent() {
</Link>
<Link
href="/sdk/einwilligungen/cookie-banner"
href="/sdk/cookie-banner"
className="bg-white rounded-xl border border-slate-200 p-4 hover:border-indigo-300 hover:shadow-md transition-all group"
>
<div className="flex items-center justify-between">
@@ -1,141 +0,0 @@
'use client'
import { useState } from 'react'
import { CookieBannerConfig, SupportedLanguage } from '@/lib/sdk/einwilligungen/types'
interface BannerPreviewProps {
config: CookieBannerConfig | null
language: SupportedLanguage
device: 'desktop' | 'tablet' | 'mobile'
}
export function BannerPreview({ config, language, device }: BannerPreviewProps) {
const [showDetails, setShowDetails] = useState(false)
if (!config) {
return (
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-xl">
<p className="text-slate-400">Konfiguration wird geladen...</p>
</div>
)
}
const isDark = config.styling.theme === 'DARK'
const bgColor = isDark ? '#1e293b' : config.styling.backgroundColor || '#ffffff'
const textColor = isDark ? '#f1f5f9' : config.styling.textColor || '#1e293b'
const deviceWidths = { desktop: '100%', tablet: '768px', mobile: '375px' }
return (
<div
className="border rounded-xl overflow-hidden"
style={{ maxWidth: deviceWidths[device], margin: '0 auto' }}
>
<div className="bg-slate-100 h-8 flex items-center px-3 gap-2">
<div className="w-3 h-3 rounded-full bg-red-400" />
<div className="w-3 h-3 rounded-full bg-yellow-400" />
<div className="w-3 h-3 rounded-full bg-green-400" />
<div className="flex-1 bg-white rounded h-5 mx-4" />
</div>
<div className="relative bg-slate-50 min-h-[400px]">
<div className="p-6 space-y-4">
<div className="h-4 bg-slate-200 rounded w-3/4" />
<div className="h-4 bg-slate-200 rounded w-1/2" />
<div className="h-32 bg-slate-200 rounded" />
<div className="h-4 bg-slate-200 rounded w-2/3" />
<div className="h-4 bg-slate-200 rounded w-1/2" />
</div>
<div className="absolute inset-0 bg-black/40" />
<div
className={`absolute ${
config.styling.position === 'TOP'
? 'top-0 left-0 right-0'
: config.styling.position === 'CENTER'
? 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'
: 'bottom-0 left-0 right-0'
}`}
style={{
maxWidth: config.styling.maxWidth,
margin: config.styling.position === 'CENTER' ? '0' : '16px auto',
}}
>
<div
className="shadow-xl"
style={{
background: bgColor,
color: textColor,
borderRadius: config.styling.borderRadius,
padding: '20px',
}}
>
<h3 className="font-semibold text-lg mb-2">{config.texts.title[language]}</h3>
<p className="text-sm opacity-80 mb-4">{config.texts.description[language]}</p>
<div className="flex flex-wrap gap-2 mb-3">
<button
style={{ background: config.styling.secondaryColor }}
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
>
{config.texts.rejectAll[language]}
</button>
<button
onClick={() => setShowDetails(!showDetails)}
style={{ background: config.styling.secondaryColor }}
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
>
{config.texts.customize[language]}
</button>
<button
style={{ background: config.styling.primaryColor, color: 'white' }}
className="flex-1 min-w-[100px] px-4 py-2 rounded-lg text-sm font-medium"
>
{config.texts.acceptAll[language]}
</button>
</div>
{showDetails && (
<div className="border-t pt-3 mt-3 space-y-2" style={{ borderColor: 'rgba(128,128,128,0.2)' }}>
{config.categories.map((cat) => (
<div key={cat.id} className="flex items-center justify-between py-2">
<div>
<div className="font-medium text-sm">{cat.name[language]}</div>
<div className="text-xs opacity-60">{cat.description[language]}</div>
</div>
<div
className={`w-10 h-6 rounded-full relative ${
cat.isRequired || cat.defaultEnabled ? '' : 'opacity-50'
}`}
style={{
background: cat.isRequired || cat.defaultEnabled
? config.styling.primaryColor
: 'rgba(128,128,128,0.3)',
}}
>
<div
className="absolute top-1 w-4 h-4 bg-white rounded-full transition-all"
style={{ left: cat.isRequired || cat.defaultEnabled ? '20px' : '4px' }}
/>
</div>
</div>
))}
<button
style={{ background: config.styling.primaryColor, color: 'white' }}
className="w-full px-4 py-2 rounded-lg text-sm font-medium mt-2"
>
{config.texts.save[language]}
</button>
</div>
)}
<a href="#" className="block text-xs mt-3" style={{ color: config.styling.primaryColor }}>
{config.texts.privacyPolicyLink[language]}
</a>
</div>
</div>
</div>
</div>
)
}
@@ -1,95 +0,0 @@
'use client'
import { useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { CookieBannerConfig, SupportedLanguage } from '@/lib/sdk/einwilligungen/types'
interface CategoryListProps {
config: CookieBannerConfig | null
language: SupportedLanguage
}
export function CategoryList({ config, language }: CategoryListProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
if (!config) return null
const toggleCategory = (id: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
return (
<div className="space-y-2">
{config.categories.map((cat) => {
const isExpanded = expandedCategories.has(cat.id)
return (
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
<button
onClick={() => toggleCategory(cat.id)}
className="w-full flex items-center justify-between p-4 hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-3">
<div
className={`w-3 h-3 rounded-full ${
cat.isRequired ? 'bg-green-500' : 'bg-amber-500'
}`}
/>
<div className="text-left">
<div className="font-medium text-slate-900">{cat.name[language]}</div>
<div className="text-sm text-slate-500">
{cat.cookies.length} Cookie(s) | {cat.dataPointIds.length} Datenpunkt(e)
</div>
</div>
</div>
<div className="flex items-center gap-2">
{cat.isRequired && (
<span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">
Erforderlich
</span>
)}
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-slate-400" />
) : (
<ChevronRight className="w-5 h-5 text-slate-400" />
)}
</div>
</button>
{isExpanded && (
<div className="px-4 pb-4 border-t border-slate-100 bg-slate-50">
<p className="text-sm text-slate-600 py-3">{cat.description[language]}</p>
{cat.cookies.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold text-slate-500 uppercase">Cookies</h4>
<div className="space-y-1">
{cat.cookies.map((cookie, idx) => (
<div
key={idx}
className="flex items-center justify-between p-2 bg-white rounded border border-slate-200"
>
<div>
<span className="font-mono text-sm text-slate-700">{cookie.name}</span>
<span className="text-xs text-slate-400 ml-2">({cookie.provider})</span>
</div>
<span className="text-xs text-slate-500">{cookie.expiry}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)
}
@@ -1,152 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { useSDK } from '@/lib/sdk'
import { useEinwilligungen } from '@/lib/sdk/einwilligungen/context'
import {
generateCookieBannerConfig,
DEFAULT_COOKIE_BANNER_TEXTS,
DEFAULT_COOKIE_BANNER_STYLING,
} from '@/lib/sdk/einwilligungen/generator/cookie-banner'
import {
CookieBannerStyling,
CookieBannerTexts,
SupportedLanguage,
} from '@/lib/sdk/einwilligungen/types'
import { Cookie, Settings, Palette, Code, Monitor, Smartphone, Tablet } from 'lucide-react'
import Link from 'next/link'
import { ArrowLeft } from 'lucide-react'
import { StylingForm } from './StylingForm'
import { TextsForm } from './TextsForm'
import { BannerPreview } from './BannerPreview'
import { EmbedCodeViewer } from './EmbedCodeViewer'
import { CategoryList } from './CategoryList'
export function CookieBannerContent() {
const { state } = useSDK()
const { allDataPoints } = useEinwilligungen()
const [styling, setStyling] = useState<CookieBannerStyling>(DEFAULT_COOKIE_BANNER_STYLING)
const [texts, setTexts] = useState<CookieBannerTexts>(DEFAULT_COOKIE_BANNER_TEXTS)
const [language, setLanguage] = useState<SupportedLanguage>('de')
const [activeTab, setActiveTab] = useState<'styling' | 'texts' | 'embed' | 'categories'>('styling')
const [device, setDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop')
const config = useMemo(() => {
return generateCookieBannerConfig(state.tenantId || 'demo', allDataPoints, texts, styling)
}, [state.tenantId, allDataPoints, texts, styling])
const cookieDataPoints = useMemo(
() => allDataPoints.filter((dp) => dp.cookieCategory !== null),
[allDataPoints]
)
return (
<div className="space-y-6">
<Link
href="/sdk/einwilligungen/catalog"
className="inline-flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<ArrowLeft className="w-4 h-4" />
Zurueck zum Katalog
</Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900">Cookie-Banner Konfiguration</h1>
<p className="text-slate-600 mt-1">Konfigurieren Sie Ihren DSGVO-konformen Cookie-Banner.</p>
</div>
<div className="flex items-center gap-2">
<select
value={language}
onChange={(e) => setLanguage(e.target.value as SupportedLanguage)}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Kategorien</div>
<div className="text-2xl font-bold text-slate-900">{config?.categories.length || 0}</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="text-sm text-slate-500">Cookie-Datenpunkte</div>
<div className="text-2xl font-bold text-indigo-600">{cookieDataPoints.length}</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-4">
<div className="text-sm text-green-600">Erforderlich</div>
<div className="text-2xl font-bold text-green-600">
{config?.categories.filter((c) => c.isRequired).length || 0}
</div>
</div>
<div className="bg-white rounded-xl border border-amber-200 p-4">
<div className="text-sm text-amber-600">Optional</div>
<div className="text-2xl font-bold text-amber-600">
{config?.categories.filter((c) => !c.isRequired).length || 0}
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex border-b border-slate-200">
{[
{ id: 'styling', label: 'Design', icon: Palette },
{ id: 'texts', label: 'Texte', icon: Settings },
{ id: 'categories', label: 'Kategorien', icon: Cookie },
{ id: 'embed', label: 'Embed-Code', icon: Code },
].map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id as typeof activeTab)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === id
? 'text-indigo-600 border-indigo-600'
: 'text-slate-600 border-transparent hover:text-slate-900'
}`}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
<div className="bg-white rounded-xl border border-slate-200 p-6">
{activeTab === 'styling' && <StylingForm styling={styling} onChange={setStyling} />}
{activeTab === 'texts' && <TextsForm texts={texts} language={language} onChange={setTexts} />}
{activeTab === 'categories' && <CategoryList config={config} language={language} />}
{activeTab === 'embed' && <EmbedCodeViewer config={config} />}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900">Vorschau</h3>
<div className="flex items-center border border-slate-200 rounded-lg overflow-hidden">
{[
{ id: 'desktop', icon: Monitor },
{ id: 'tablet', icon: Tablet },
{ id: 'mobile', icon: Smartphone },
].map(({ id, icon: Icon }) => (
<button
key={id}
onClick={() => setDevice(id as typeof device)}
className={`p-2 ${
device === id ? 'bg-indigo-50 text-indigo-600' : 'text-slate-400 hover:text-slate-600'
}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
<BannerPreview config={config} language={language} device={device} />
</div>
</div>
</div>
)
}
@@ -1,96 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { Copy, Check } from 'lucide-react'
import { CookieBannerConfig } from '@/lib/sdk/einwilligungen/types'
import { generateEmbedCode } from '@/lib/sdk/einwilligungen/generator/cookie-banner'
interface EmbedCodeViewerProps {
config: CookieBannerConfig | null
}
export function EmbedCodeViewer({ config }: EmbedCodeViewerProps) {
const [activeTab, setActiveTab] = useState<'script' | 'html' | 'css' | 'js'>('script')
const [copied, setCopied] = useState(false)
const embedCode = useMemo(() => {
if (!config) return null
return generateEmbedCode(config, '/datenschutz')
}, [config])
const copyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (!embedCode) {
return (
<div className="flex items-center justify-center h-48 bg-slate-100 rounded-xl">
<p className="text-slate-400">Embed-Code wird generiert...</p>
</div>
)
}
const tabs = [
{ id: 'script', label: 'Script-Tag', content: embedCode.scriptTag },
{ id: 'html', label: 'HTML', content: embedCode.html },
{ id: 'css', label: 'CSS', content: embedCode.css },
{ id: 'js', label: 'JavaScript', content: embedCode.js },
] as const
const currentContent = tabs.find((t) => t.id === activeTab)?.content || ''
return (
<div className="border border-slate-200 rounded-xl overflow-hidden">
<div className="flex border-b border-slate-200 bg-slate-50">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-white text-indigo-600 border-b-2 border-indigo-600 -mb-px'
: 'text-slate-600 hover:text-slate-900'
}`}
>
{tab.label}
</button>
))}
</div>
<div className="relative">
<pre className="p-4 bg-slate-900 text-slate-100 text-sm font-mono overflow-x-auto max-h-[400px]">
{currentContent}
</pre>
<button
onClick={() => copyToClipboard(currentContent)}
className="absolute top-3 right-3 flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-lg text-xs"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-green-400" />
Kopiert
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
Kopieren
</>
)}
</button>
</div>
{activeTab === 'script' && (
<div className="p-4 bg-amber-50 border-t border-amber-200">
<p className="text-sm text-amber-800">
<strong>Integration:</strong> Fuegen Sie den Script-Tag in den{' '}
<code className="bg-amber-100 px-1 rounded">&lt;head&gt;</code> oder vor dem
schliessenden{' '}
<code className="bg-amber-100 px-1 rounded">&lt;/body&gt;</code>-Tag ein.
</p>
</div>
)}
</div>
)
}
@@ -1,118 +0,0 @@
'use client'
import { CookieBannerStyling } from '@/lib/sdk/einwilligungen/types'
interface StylingFormProps {
styling: CookieBannerStyling
onChange: (styling: CookieBannerStyling) => void
}
export function StylingForm({ styling, onChange }: StylingFormProps) {
const handleChange = (field: keyof CookieBannerStyling, value: string | number) => {
onChange({ ...styling, [field]: value })
}
return (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Position</label>
<div className="grid grid-cols-3 gap-2">
{(['BOTTOM', 'TOP', 'CENTER'] as const).map((pos) => (
<button
key={pos}
onClick={() => handleChange('position', pos)}
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
styling.position === pos
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{pos === 'BOTTOM' ? 'Unten' : pos === 'TOP' ? 'Oben' : 'Zentriert'}
</button>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Theme</label>
<div className="grid grid-cols-2 gap-2">
{(['LIGHT', 'DARK'] as const).map((theme) => (
<button
key={theme}
onClick={() => handleChange('theme', theme)}
className={`px-4 py-2 text-sm rounded-lg border transition-colors ${
styling.theme === theme
? 'bg-indigo-50 border-indigo-300 text-indigo-700'
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{theme === 'LIGHT' ? 'Hell' : 'Dunkel'}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Primaerfarbe</label>
<div className="flex items-center gap-2">
<input
type="color"
value={styling.primaryColor}
onChange={(e) => handleChange('primaryColor', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={styling.primaryColor}
onChange={(e) => handleChange('primaryColor', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Sekundaerfarbe</label>
<div className="flex items-center gap-2">
<input
type="color"
value={styling.secondaryColor || '#f1f5f9'}
onChange={(e) => handleChange('secondaryColor', e.target.value)}
className="w-10 h-10 rounded border border-slate-200 cursor-pointer"
/>
<input
type="text"
value={styling.secondaryColor || '#f1f5f9'}
onChange={(e) => handleChange('secondaryColor', e.target.value)}
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Eckenradius (px)</label>
<input
type="number"
min={0}
max={32}
value={styling.borderRadius}
onChange={(e) => handleChange('borderRadius', parseInt(e.target.value))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Breite (px)</label>
<input
type="number"
min={320}
max={800}
value={styling.maxWidth}
onChange={(e) => handleChange('maxWidth', parseInt(e.target.value))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
</div>
)
}
@@ -1,53 +0,0 @@
'use client'
import { CookieBannerTexts, SupportedLanguage } from '@/lib/sdk/einwilligungen/types'
interface TextsFormProps {
texts: CookieBannerTexts
language: SupportedLanguage
onChange: (texts: CookieBannerTexts) => void
}
export function TextsForm({ texts, language, onChange }: TextsFormProps) {
const handleChange = (field: keyof CookieBannerTexts, value: string) => {
onChange({
...texts,
[field]: { ...texts[field], [language]: value },
})
}
const fields: { key: keyof CookieBannerTexts; label: string; multiline?: boolean }[] = [
{ key: 'title', label: 'Titel' },
{ key: 'description', label: 'Beschreibung', multiline: true },
{ key: 'acceptAll', label: 'Alle akzeptieren Button' },
{ key: 'rejectAll', label: 'Nur notwendige Button' },
{ key: 'customize', label: 'Einstellungen Button' },
{ key: 'save', label: 'Speichern Button' },
{ key: 'privacyPolicyLink', label: 'Datenschutz-Link Text' },
]
return (
<div className="space-y-4">
{fields.map(({ key, label, multiline }) => (
<div key={key}>
<label className="block text-sm font-medium text-slate-700 mb-1">{label}</label>
{multiline ? (
<textarea
value={texts[key][language]}
onChange={(e) => handleChange(key, e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
) : (
<input
type="text"
value={texts[key][language]}
onChange={(e) => handleChange(key, e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
)}
</div>
))}
</div>
)
}
@@ -1,18 +1,5 @@
'use client'
import { redirect } from 'next/navigation'
/**
* Cookie Banner Configuration Page
*
* Konfiguriert den Cookie-Banner basierend auf dem Datenpunktkatalog.
*/
import { EinwilligungenProvider } from '@/lib/sdk/einwilligungen/context'
import { CookieBannerContent } from './_components/CookieBannerContent'
export default function CookieBannerPage() {
return (
<EinwilligungenProvider>
<CookieBannerContent />
</EinwilligungenProvider>
)
export default function CookieBannerRedirect() {
redirect('/sdk/cookie-banner')
}
@@ -2,7 +2,7 @@
import { useState } from 'react'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import { History } from 'lucide-react'
import { History, Globe, User } from 'lucide-react'
import { ConsentRecord } from './_types'
import { useConsents } from './_hooks/useConsents'
@@ -12,8 +12,13 @@ import { SearchAndFilter } from './_components/SearchAndFilter'
import { RecordsTable } from './_components/RecordsTable'
import { Pagination } from './_components/Pagination'
import { ConsentDetailModal } from './_components/ConsentDetailModal'
import BannerConsentsTab from './_components/BannerConsentsTab'
type ConsentTab = 'visitors' | 'users'
export default function EinwilligungenPage() {
const [activeTab, setActiveTab] = useState<ConsentTab>('visitors')
const {
records,
currentPage,
@@ -63,51 +68,84 @@ export default function EinwilligungenPage() {
{/* Navigation Tabs */}
<EinwilligungenNavTabs />
{/* Stats */}
<StatsGrid
total={globalStats.total}
active={globalStats.active}
revoked={globalStats.revoked}
versionUpdates={versionUpdates}
/>
{/* Info Banner */}
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-4 flex items-start gap-3">
<History className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
<div className="text-sm text-purple-700">
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten.
Klicken Sie auf "Details" um die vollständige Historie eines Nutzers einzusehen.
</div>
</div>
{/* Consent Type Tabs: Website-Besucher / Login-Nutzer */}
<div className="flex gap-1 p-1 bg-gray-100 rounded-xl w-fit">
<button
onClick={() => setActiveTab('visitors')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'visitors'
? 'bg-white text-purple-700 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Globe className="w-4 h-4" />
Website-Besucher
</button>
<button
onClick={() => setActiveTab('users')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === 'users'
? 'bg-white text-purple-700 shadow-sm'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<User className="w-4 h-4" />
Login-Nutzer
</button>
</div>
{/* Search and Filter */}
<SearchAndFilter
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filter={filter}
onFilterChange={setFilter}
/>
{/* Tab Content */}
{activeTab === 'visitors' ? (
<BannerConsentsTab />
) : (
<>
{/* Stats */}
<StatsGrid
total={globalStats.total}
active={globalStats.active}
revoked={globalStats.revoked}
versionUpdates={versionUpdates}
/>
{/* Records Table */}
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
{/* Info Banner */}
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-200 p-4 flex items-start gap-3">
<History className="w-5 h-5 text-purple-600 mt-0.5" />
<div>
<div className="font-medium text-purple-900">Consent-Historie aktiviert</div>
<div className="text-sm text-purple-700">
Alle Änderungen an Einwilligungen werden protokolliert, inkl. Zustimmungen zu neuen Versionen von AGB, DSI und anderen Dokumenten.
Klicken Sie auf &quot;Details&quot; um die vollständige Historie eines Nutzers einzusehen.
</div>
</div>
</div>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalRecords={totalRecords}
onPageChange={setCurrentPage}
/>
{/* Search and Filter */}
<SearchAndFilter
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filter={filter}
onFilterChange={setFilter}
/>
{/* Detail Modal */}
{selectedRecord && (
<ConsentDetailModal
record={selectedRecord}
onClose={() => setSelectedRecord(null)}
onRevoke={handleRevoke}
/>
{/* Records Table */}
<RecordsTable records={filteredRecords} onShowDetails={setSelectedRecord} />
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalRecords={totalRecords}
onPageChange={setCurrentPage}
/>
{/* Detail Modal */}
{selectedRecord && (
<ConsentDetailModal
record={selectedRecord}
onClose={() => setSelectedRecord(null)}
onRevoke={handleRevoke}
/>
)}
</>
)}
</div>
)
@@ -0,0 +1,261 @@
'use client'
import React, { useState } from 'react'
interface GapReport {
dsms_cid?: string
profile_name: string
regulations: Array<{
id: string
name: string
risk_level: string
confidence: number
reasoning: string
requirements?: string[]
}>
summary: {
total_applicable_regulations: number
total_gaps: number
gaps_by_status: Record<string, number>
gaps_by_severity: Record<string, number>
overall_compliance_percent: number
estimated_effort_weeks: number
}
gaps: Array<{
mc_id: string
mc_name: string
regulation: string
status: string
title: string
severity: string
priority: { score: number; rank: number }
recommendation: string
control_count: number
}>
}
interface Props {
report: GapReport
onBack: () => void
}
const STATUS_COLORS: Record<string, string> = {
fulfilled: 'bg-green-100 text-green-800',
partial: 'bg-yellow-100 text-yellow-800',
missing: 'bg-red-100 text-red-800',
unclear: 'bg-gray-100 text-gray-800',
}
const STATUS_LABELS: Record<string, string> = {
fulfilled: 'Erfuellt',
partial: 'Teilweise',
missing: 'Offen',
unclear: 'Unklar',
}
const SEVERITY_COLORS: Record<string, string> = {
CRITICAL: 'bg-red-600 text-white',
HIGH: 'bg-orange-500 text-white',
MEDIUM: 'bg-yellow-400 text-gray-900',
LOW: 'bg-blue-100 text-blue-800',
}
export function GapDashboard({ report, onBack }: Props) {
const [filterSeverity, setFilterSeverity] = useState<string>('all')
const [filterStatus, setFilterStatus] = useState<string>('all')
const [expandedGap, setExpandedGap] = useState<string | null>(null)
const filteredGaps = report.gaps.filter(g => {
if (filterSeverity !== 'all' && g.severity !== filterSeverity) return false
if (filterStatus !== 'all' && g.status !== filterStatus) return false
return true
})
const s = report.summary
return (
<div>
{/* Back button */}
<button onClick={onBack} className="mb-6 text-blue-600 hover:text-blue-800 text-sm">
&larr; Neue Analyse
</button>
{/* DSMS Archive Badge */}
{report.dsms_cid && (
<div className="mb-4 flex items-center gap-2 px-4 py-2.5 bg-emerald-50 border border-emerald-200 rounded-lg">
<svg className="w-4 h-4 text-emerald-600 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span className="text-sm text-emerald-800 font-medium">Revisionssicher archiviert</span>
<code className="text-xs text-emerald-600 bg-emerald-100 px-2 py-0.5 rounded font-mono">
{report.dsms_cid.length > 20 ? report.dsms_cid.slice(0, 8) + '...' + report.dsms_cid.slice(-6) : report.dsms_cid}
</code>
<span className="text-[10px] text-emerald-500">DSMS/IPFS</span>
</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<SummaryCard
label="Regulierungen"
value={s.total_applicable_regulations}
color="blue"
/>
<SummaryCard
label="Offene Gaps"
value={s.gaps_by_status?.missing || 0}
color="red"
/>
<SummaryCard
label="Compliance"
value={`${s.overall_compliance_percent}%`}
color={s.overall_compliance_percent >= 80 ? 'green' : 'orange'}
/>
<SummaryCard
label="Gesch. Aufwand"
value={`${s.estimated_effort_weeks} Wo.`}
color="purple"
/>
</div>
{/* Applicable Regulations */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Anwendbare Regulierungen
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{report.regulations.map(reg => (
<div
key={reg.id}
className="border border-gray-200 rounded-lg p-4 hover:shadow-sm transition-shadow"
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-900 text-sm">
{reg.name}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
reg.risk_level === 'high' ? 'bg-red-100 text-red-700' :
reg.risk_level === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
{reg.risk_level}
</span>
</div>
<p className="text-xs text-gray-500">{reg.reasoning}</p>
</div>
))}
</div>
</div>
{/* Filters */}
<div className="flex gap-4 mb-4">
<select
value={filterSeverity}
onChange={e => setFilterSeverity(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="all">Alle Prioritaeten</option>
<option value="CRITICAL">Kritisch</option>
<option value="HIGH">Hoch</option>
<option value="MEDIUM">Mittel</option>
<option value="LOW">Niedrig</option>
</select>
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="all">Alle Status</option>
<option value="missing">Offen</option>
<option value="partial">Teilweise</option>
<option value="fulfilled">Erfuellt</option>
</select>
<span className="text-sm text-gray-500 self-center">
{filteredGaps.length} von {report.gaps.length} Anforderungen
</span>
</div>
{/* Gap List */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">#</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Anforderung</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Regulierung</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Prioritaet</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Controls</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredGaps.map(gap => (
<React.Fragment key={gap.mc_id}>
<tr
className="hover:bg-gray-50 cursor-pointer"
onClick={() => setExpandedGap(expandedGap === gap.mc_id ? null : gap.mc_id)}
>
<td className="px-4 py-3 text-sm text-gray-500">{gap.priority.rank}</td>
<td className="px-4 py-3">
<div className="text-sm font-medium text-gray-900">{gap.title}</div>
<div className="text-xs text-gray-500">{gap.mc_name}</div>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{gap.regulation}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${STATUS_COLORS[gap.status] || ''}`}>
{STATUS_LABELS[gap.status] || gap.status}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 rounded text-xs font-bold ${SEVERITY_COLORS[gap.severity] || ''}`}>
{gap.severity}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500">{gap.control_count}</td>
</tr>
{expandedGap === gap.mc_id && (
<tr>
<td colSpan={6} className="px-4 py-4 bg-blue-50">
<div className="text-sm">
<p className="font-medium text-gray-700 mb-1">Empfehlung:</p>
<p className="text-gray-600">{gap.recommendation}</p>
<p className="mt-2 text-xs text-gray-400">
Priority Score: {gap.priority.score.toFixed(1)} | MC: {gap.mc_id}
</p>
</div>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
</div>
)
}
function SummaryCard({ label, value, color }: { label: string; value: string | number; color: string }) {
const bg = {
blue: 'bg-blue-50 border-blue-200',
red: 'bg-red-50 border-red-200',
green: 'bg-green-50 border-green-200',
orange: 'bg-orange-50 border-orange-200',
purple: 'bg-purple-50 border-purple-200',
}[color] || 'bg-gray-50 border-gray-200'
const text = {
blue: 'text-blue-700',
red: 'text-red-700',
green: 'text-green-700',
orange: 'text-orange-700',
purple: 'text-purple-700',
}[color] || 'text-gray-700'
return (
<div className={`rounded-xl border p-4 ${bg}`}>
<p className="text-sm text-gray-600">{label}</p>
<p className={`text-2xl font-bold mt-1 ${text}`}>{value}</p>
</div>
)
}
@@ -0,0 +1,166 @@
'use client'
import React from 'react'
const NORMS = [
{ value: 'ISO12100', label: 'ISO 12100 (Maschinensicherheit)' },
{ value: 'ENISO13849', label: 'EN ISO 13849 (Sicherheitsfunktionen)' },
{ value: 'IEC61508', label: 'IEC 61508 (Funktionale Sicherheit)' },
{ value: 'IEC62443', label: 'IEC 62443 (Industrielle Cybersecurity)' },
{ value: 'ISO27001', label: 'ISO 27001 (Informationssicherheit)' },
{ value: 'ISO27002', label: 'ISO 27002 (Security Controls)' },
{ value: 'EN61326', label: 'EN 61326 (EMV)' },
{ value: 'EN62368', label: 'EN 62368 (Audio/Video/IT-Sicherheit)' },
{ value: 'IEC60204', label: 'IEC 60204 (Elektrische Ausruestung)' },
{ value: 'ISO13485', label: 'ISO 13485 (Medizinprodukte QM)' },
{ value: 'ISO14971', label: 'ISO 14971 (Risikomanagement Medizin)' },
{ value: 'IEC62304', label: 'IEC 62304 (Medizin-Software Lifecycle)' },
{ value: 'ISO9001', label: 'ISO 9001 (Qualitaetsmanagement)' },
{ value: 'ISO22301', label: 'ISO 22301 (Business Continuity)' },
{ value: 'PCIDSS', label: 'PCI DSS (Zahlungssicherheit)' },
{ value: 'EN50581', label: 'EN 50581 (RoHS/REACH)' },
{ value: 'ASPICE', label: 'ASPICE (Automotive Software)' },
]
interface IstData {
applied_norms: string[]
has_risk_assessment: boolean
has_technical_file: boolean
has_operating_manual: boolean
has_sbom: boolean
has_vuln_management: boolean
has_update_mechanism: boolean
has_incident_response: boolean
has_supply_chain_mgmt: boolean
ce_marking_since: string
product_age: string
}
interface Props {
data: IstData
onChange: (data: IstData) => void
}
export function IstAssessment({ data, onChange }: Props) {
const update = (field: string, value: unknown) => {
onChange({ ...data, [field]: value })
}
const toggleNorm = (norm: string) => {
const norms = data.applied_norms.includes(norm)
? data.applied_norms.filter(n => n !== norm)
: [...data.applied_norms, norm]
update('applied_norms', norms)
}
return (
<div className="space-y-8">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-800 text-sm">
Geben Sie an was Sie bereits haben. Je mehr wir wissen, desto
praeziser ist die Gap-Analyse. Controls die bereits erfuellt sind
werden automatisch als &quot;erledigt&quot; markiert.
</p>
</div>
{/* CE-Kennzeichnung */}
<div>
<h3 className="text-sm font-semibold text-gray-800 mb-3">CE-Kennzeichnung</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">CE seit (Jahr)</label>
<input
type="text"
value={data.ce_marking_since}
onChange={e => update('ce_marking_since', e.target.value)}
placeholder="z.B. 2016"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Produktalter</label>
<select
value={data.product_age}
onChange={e => update('product_age', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">Bitte waehlen</option>
<option value="new">Neues Produkt (noch nicht am Markt)</option>
<option value="1_year">1 Jahr</option>
<option value="3_years">2-3 Jahre</option>
<option value="5_years">4-5 Jahre</option>
<option value="10_years">6-10 Jahre</option>
<option value="10_plus">Ueber 10 Jahre</option>
</select>
</div>
</div>
</div>
{/* Angewandte Normen */}
<div>
<h3 className="text-sm font-semibold text-gray-800 mb-3">Angewandte Normen</h3>
<div className="flex flex-wrap gap-2">
{NORMS.map(n => (
<button
key={n.value}
onClick={() => toggleNorm(n.value)}
className={`px-3 py-1.5 rounded-full text-xs border transition-colors ${
data.applied_norms.includes(n.value)
? 'bg-green-100 border-green-400 text-green-800'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{n.label}
</button>
))}
</div>
</div>
{/* Bestehende Dokumentation */}
<div>
<h3 className="text-sm font-semibold text-gray-800 mb-3">Bestehende Dokumentation</h3>
<div className="grid grid-cols-2 gap-3">
{[
{ field: 'has_risk_assessment', label: 'Risikobeurteilung vorhanden' },
{ field: 'has_technical_file', label: 'Technische Dokumentation vorhanden' },
{ field: 'has_operating_manual', label: 'Betriebsanleitung vorhanden' },
{ field: 'has_sbom', label: 'SBOM (Software Bill of Materials)' },
].map(item => (
<label key={item.field} className="flex items-center gap-3 cursor-pointer p-2 rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={(data as Record<string, unknown>)[item.field] as boolean}
onChange={e => update(item.field, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-green-600"
/>
<span className="text-sm text-gray-700">{item.label}</span>
</label>
))}
</div>
</div>
{/* Bestehende Prozesse */}
<div>
<h3 className="text-sm font-semibold text-gray-800 mb-3">Bestehende Prozesse</h3>
<div className="grid grid-cols-2 gap-3">
{[
{ field: 'has_vuln_management', label: 'Schwachstellenmanagement' },
{ field: 'has_update_mechanism', label: 'Software-Update-Mechanismus' },
{ field: 'has_incident_response', label: 'Incident Response Prozess' },
{ field: 'has_supply_chain_mgmt', label: 'Lieferketten-Management' },
].map(item => (
<label key={item.field} className="flex items-center gap-3 cursor-pointer p-2 rounded-lg hover:bg-gray-50">
<input
type="checkbox"
checked={(data as Record<string, unknown>)[item.field] as boolean}
onChange={e => update(item.field, e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-green-600"
/>
<span className="text-sm text-gray-700">{item.label}</span>
</label>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,302 @@
'use client'
import React, { useState } from 'react'
import { IstAssessment } from './IstAssessment'
const PRODUCT_TYPES = [
{ value: 'iot', label: 'IoT / Connected Device' },
{ value: 'software', label: 'Software / Desktop App' },
{ value: 'saas', label: 'SaaS / Cloud-Plattform' },
{ value: 'hardware', label: 'Hardware / Elektronik' },
{ value: 'machinery', label: 'Maschine / Anlage' },
{ value: 'medical_device', label: 'Medizinprodukt' },
{ value: 'exchange', label: 'Krypto-Exchange / Fintech' },
{ value: 'other', label: 'Sonstiges' },
]
const TECHNOLOGIES = [
{ value: 'ai', label: 'Kuenstliche Intelligenz / ML' },
{ value: 'blockchain', label: 'Blockchain / Smart Contracts' },
{ value: 'cloud', label: 'Cloud-Infrastruktur' },
{ value: 'api', label: 'REST/GraphQL API' },
{ value: 'database', label: 'Datenbank' },
{ value: 'encryption', label: 'Verschluesselung' },
{ value: 'ota_updates', label: 'OTA Software-Updates' },
{ value: 'sensor', label: 'Sensoren' },
{ value: 'actuator', label: 'Aktoren / Motoren' },
{ value: 'network', label: 'Netzwerk-Anbindung' },
{ value: 'camera', label: 'Kamera / Bilderkennung' },
{ value: 'payment', label: 'Zahlungsabwicklung' },
{ value: 'fiat_gateway', label: 'Fiat On/Off-Ramp' },
]
const DATA_TYPES = [
{ value: 'personal_data', label: 'Personenbezogene Daten' },
{ value: 'health_data', label: 'Gesundheitsdaten' },
{ value: 'financial_data', label: 'Finanzdaten' },
{ value: 'telemetry', label: 'Telemetrie / Nutzungsdaten' },
]
const CERTIFICATIONS = [
{ value: 'ISO27001', label: 'ISO 27001' },
{ value: 'CE', label: 'CE-Kennzeichnung' },
{ value: 'SOC2', label: 'SOC 2' },
{ value: 'ISO13485', label: 'ISO 13485 (Medizin)' },
]
interface Props {
onAnalyze: (profile: Record<string, unknown>) => void
loading: boolean
}
export function ProductWizard({ onAnalyze, loading }: Props) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [productType, setProductType] = useState('')
const [technologies, setTechnologies] = useState<string[]>([])
const [dataProcessing, setDataProcessing] = useState<string[]>([])
const [certifications, setCertifications] = useState<string[]>([])
const [connectedToInternet, setConnectedToInternet] = useState(false)
const [hasSoftwareUpdates, setHasSoftwareUpdates] = useState(false)
const [usesAI, setUsesAI] = useState(false)
const [processesPersonalData, setProcessesPersonalData] = useState(false)
const [isCriticalInfra, setIsCriticalInfra] = useState(false)
const [step, setStep] = useState(1)
const [istData, setIstData] = useState({
applied_norms: [] as string[],
has_risk_assessment: false,
has_technical_file: false,
has_operating_manual: false,
has_sbom: false,
has_vuln_management: false,
has_update_mechanism: false,
has_incident_response: false,
has_supply_chain_mgmt: false,
ce_marking_since: '',
product_age: '',
})
const toggleArrayValue = (
arr: string[],
setter: (v: string[]) => void,
value: string
) => {
setter(arr.includes(value) ? arr.filter(v => v !== value) : [...arr, value])
}
const handleSubmit = () => {
onAnalyze({
name: name || 'Unbenanntes Produkt',
description,
product_type: productType,
technologies,
data_processing: dataProcessing,
markets: ['EU'],
connected_to_internet: connectedToInternet,
has_software_updates: hasSoftwareUpdates,
uses_ai: usesAI,
processes_personal_data: processesPersonalData,
is_critical_infra_supplier: isCriticalInfra,
existing_certifications: certifications,
...istData,
})
}
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
{/* Step Indicator */}
<div className="flex items-center gap-4 mb-8">
<button
onClick={() => setStep(1)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
step === 1 ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-50'
}`}
>
<span className="w-6 h-6 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center">1</span>
Produkt beschreiben
</button>
<span className="text-gray-300">&rarr;</span>
<button
onClick={() => productType ? setStep(2) : null}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
step === 2 ? 'bg-blue-100 text-blue-700' : 'text-gray-500 hover:bg-gray-50'
} ${!productType ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span className={`w-6 h-6 rounded-full text-xs flex items-center justify-center ${
step === 2 ? 'bg-blue-600 text-white' : 'bg-gray-300 text-white'
}`}>2</span>
IST-Zustand
</button>
</div>
{step === 2 && (
<>
<IstAssessment data={istData} onChange={setIstData} />
<div className="flex gap-4 mt-8">
<button
onClick={() => setStep(1)}
className="px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Zurueck
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="flex-1 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-300 transition-colors"
>
{loading ? 'Analyse laeuft...' : 'Gap-Analyse starten'}
</button>
</div>
</>
)}
{step === 1 && (<>
{/* Produktname */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Produktname
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="z.B. SmartFactory Gateway Pro"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Beschreibung */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Produktbeschreibung
</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
placeholder="Beschreiben Sie Ihr Produkt in 2-3 Saetzen..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Produkttyp */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Produkttyp
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{PRODUCT_TYPES.map(pt => (
<button
key={pt.value}
onClick={() => setProductType(pt.value)}
className={`px-4 py-3 rounded-lg border text-sm font-medium transition-colors ${
productType === pt.value
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'border-gray-200 text-gray-700 hover:bg-gray-50'
}`}
>
{pt.label}
</button>
))}
</div>
</div>
{/* Technologien */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Verwendete Technologien
</label>
<div className="flex flex-wrap gap-2">
{TECHNOLOGIES.map(t => (
<button
key={t.value}
onClick={() => toggleArrayValue(technologies, setTechnologies, t.value)}
className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
technologies.includes(t.value)
? 'bg-blue-100 border-blue-400 text-blue-800'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{t.label}
</button>
))}
</div>
</div>
{/* Datenverarbeitung */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Verarbeitete Daten
</label>
<div className="flex flex-wrap gap-2">
{DATA_TYPES.map(d => (
<button
key={d.value}
onClick={() => toggleArrayValue(dataProcessing, setDataProcessing, d.value)}
className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
dataProcessing.includes(d.value)
? 'bg-green-100 border-green-400 text-green-800'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{d.label}
</button>
))}
</div>
</div>
{/* Checkboxen */}
<div className="mb-6 grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{ label: 'Mit dem Internet verbunden', value: connectedToInternet, setter: setConnectedToInternet },
{ label: 'Hat Software-Updates (OTA)', value: hasSoftwareUpdates, setter: setHasSoftwareUpdates },
{ label: 'Verwendet KI / Machine Learning', value: usesAI, setter: setUsesAI },
{ label: 'Verarbeitet personenbezogene Daten', value: processesPersonalData, setter: setProcessesPersonalData },
{ label: 'Zulieferer fuer kritische Infrastruktur', value: isCriticalInfra, setter: setIsCriticalInfra },
].map(cb => (
<label key={cb.label} className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={cb.value}
onChange={e => cb.setter(e.target.checked)}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">{cb.label}</span>
</label>
))}
</div>
{/* Bestehende Zertifizierungen */}
<div className="mb-8">
<label className="block text-sm font-medium text-gray-700 mb-2">
Bestehende Zertifizierungen (optional)
</label>
<div className="flex flex-wrap gap-2">
{CERTIFICATIONS.map(cert => (
<button
key={cert.value}
onClick={() => toggleArrayValue(certifications, setCertifications, cert.value)}
className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
certifications.includes(cert.value)
? 'bg-purple-100 border-purple-400 text-purple-800'
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
{cert.label}
</button>
))}
</div>
</div>
{/* Next Step */}
<button
onClick={() => setStep(2)}
disabled={!productType}
className="w-full py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
Weiter: IST-Zustand erfassen &rarr;
</button>
</>)}
</div>
)
}
@@ -0,0 +1,220 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { ProductWizard } from './_components/ProductWizard'
import { GapDashboard } from './_components/GapDashboard'
interface GapProject {
id: string
name: string
description: string
product_type: string
created_at: string
}
interface GapReport {
profile_id: string
profile_name: string
regulations: Array<{
id: string
name: string
applicable: boolean
confidence: number
reasoning: string
risk_level: string
deadline?: string
requirements?: string[]
}>
summary: {
total_applicable_regulations: number
total_gaps: number
gaps_by_status: Record<string, number>
gaps_by_severity: Record<string, number>
gaps_by_regulation: Record<string, number>
overall_compliance_percent: number
estimated_effort_weeks: number
}
gaps: Array<{
mc_id: string
mc_name: string
regulation: string
status: string
title: string
severity: string
priority: { score: number; rank: number }
recommendation: string
control_count: number
}>
}
type View = 'projects' | 'wizard' | 'dashboard'
const PRODUCT_TYPE_LABELS: Record<string, string> = {
iot: 'IoT', software: 'Software', saas: 'SaaS', hardware: 'Hardware',
machinery: 'Maschine', medical_device: 'Medizin', exchange: 'Fintech', other: 'Sonstiges',
}
export default function GapAnalysisPage() {
const [view, setView] = useState<View>('projects')
const [projects, setProjects] = useState<GapProject[]>([])
const [report, setReport] = useState<GapReport | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const loadProjects = useCallback(async () => {
try {
const res = await fetch('/api/sdk/v1/gap/projects', {
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
})
if (res.ok) {
const data = await res.json()
setProjects(data.projects || [])
}
} catch { /* ignore */ }
}, [])
useEffect(() => { loadProjects() }, [loadProjects])
const handleCreateAndAnalyze = async (profile: Record<string, unknown>) => {
setLoading(true)
setError('')
try {
// Save project
const createRes = await fetch('/api/sdk/v1/gap/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': '00000000-0000-0000-0000-000000000001',
},
body: JSON.stringify(profile),
})
if (!createRes.ok) throw new Error('Projekt konnte nicht gespeichert werden')
const created = await createRes.json()
const projectId = created.project?.id
// Run analysis
const analyzeRes = await fetch(`/api/sdk/v1/gap/projects/${projectId}/analyze`, {
method: 'POST',
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
})
if (!analyzeRes.ok) throw new Error(await analyzeRes.text())
const data = await analyzeRes.json()
setReport(data)
setView('dashboard')
loadProjects()
} catch (e) {
setError(e instanceof Error ? e.message : 'Analyse fehlgeschlagen')
} finally {
setLoading(false)
}
}
const handleOpenProject = async (projectId: string) => {
setLoading(true)
setError('')
try {
const res = await fetch(`/api/sdk/v1/gap/projects/${projectId}/analyze`, {
method: 'POST',
headers: { 'X-Tenant-ID': '00000000-0000-0000-0000-000000000001' },
})
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
setReport(data)
setView('dashboard')
} catch (e) {
setError(e instanceof Error ? e.message : 'Analyse fehlgeschlagen')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Regulatory Gap-Analyse
</h1>
<p className="text-gray-600 mt-2">
Produkt beschreiben, Regulierungen erkennen, Prioritaeten setzen.
</p>
</div>
{view !== 'projects' && (
<button
onClick={() => { setView('projects'); setReport(null) }}
className="px-4 py-2 text-sm text-blue-600 hover:text-blue-800 border border-blue-200 rounded-lg hover:bg-blue-50"
>
Alle Projekte
</button>
)}
</div>
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-700">{error}</p>
<button onClick={() => setError('')} className="text-sm text-red-500 mt-1 underline">
Schliessen
</button>
</div>
)}
{view === 'projects' && (
<div>
{/* New project button */}
<button
onClick={() => setView('wizard')}
className="mb-6 w-full py-4 border-2 border-dashed border-blue-300 rounded-xl text-blue-600 hover:bg-blue-50 hover:border-blue-400 transition-colors font-medium"
>
+ Neues Produkt analysieren
</button>
{/* Project list */}
{projects.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold text-gray-800">Gespeicherte Projekte</h2>
{projects.map(p => (
<button
key={p.id}
onClick={() => handleOpenProject(p.id)}
disabled={loading}
className="w-full text-left bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md hover:border-blue-300 transition-all disabled:opacity-50"
>
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-gray-900">{p.name}</h3>
<p className="text-sm text-gray-500 mt-1">{p.description}</p>
</div>
<div className="flex items-center gap-3">
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-xs font-medium">
{PRODUCT_TYPE_LABELS[p.product_type] || p.product_type}
</span>
<span className="text-xs text-gray-400">
{new Date(p.created_at).toLocaleDateString('de-DE')}
</span>
</div>
</div>
</button>
))}
</div>
)}
{projects.length === 0 && (
<p className="text-center text-gray-500 mt-8">
Noch keine Projekte. Starten Sie Ihre erste Gap-Analyse.
</p>
)}
</div>
)}
{view === 'wizard' && (
<ProductWizard onAnalyze={handleCreateAndAnalyze} loading={loading} />
)}
{view === 'dashboard' && report && (
<GapDashboard report={report} onBack={() => { setView('projects'); setReport(null) }} />
)}
</div>
</div>
)
}
@@ -0,0 +1,235 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
interface ComplianceTrigger {
id: string
regulation: string
article: string
title: string
severity: 'high' | 'medium' | 'low'
reason: string
affected_hazard_count?: number
module_path: string
module_label: string
}
interface TriggersResponse {
triggers: ComplianceTrigger[]
total: number
}
const SEVERITY_CONFIG: Record<string, { border: string; bg: string; text: string; badge: string; icon: string }> = {
high: {
border: 'border-red-200 dark:border-red-800',
bg: 'bg-red-50 dark:bg-red-900/20',
text: 'text-red-700 dark:text-red-400',
badge: 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300',
icon: 'text-red-500',
},
medium: {
border: 'border-yellow-200 dark:border-yellow-800',
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
text: 'text-yellow-700 dark:text-yellow-400',
badge: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300',
icon: 'text-yellow-500',
},
low: {
border: 'border-blue-200 dark:border-blue-800',
bg: 'bg-blue-50 dark:bg-blue-900/20',
text: 'text-blue-700 dark:text-blue-400',
badge: 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300',
icon: 'text-blue-500',
},
}
const SEVERITY_LABELS: Record<string, string> = {
high: 'HOCH',
medium: 'MITTEL',
low: 'NIEDRIG',
}
const REGULATION_BADGES: { key: string; label: string; activeColor: string }[] = [
{ key: 'DSGVO', label: 'DSGVO', activeColor: 'bg-red-100 text-red-800 border-red-300' },
{ key: 'AI Act', label: 'AI Act', activeColor: 'bg-orange-100 text-orange-800 border-orange-300' },
{ key: 'CRA', label: 'CRA', activeColor: 'bg-yellow-100 text-yellow-800 border-yellow-300' },
{ key: 'NIS2', label: 'NIS2', activeColor: 'bg-indigo-100 text-indigo-800 border-indigo-300' },
{ key: 'Data Act', label: 'Data Act', activeColor: 'bg-amber-100 text-amber-800 border-amber-300' },
]
function WarningIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
)
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg className={`w-4 h-4 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)
}
export function ComplianceAlerts({ projectId }: { projectId: string }) {
const [data, setData] = useState<TriggersResponse | null>(null)
const [loading, setLoading] = useState(true)
const [collapsed, setCollapsed] = useState(false)
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}/compliance-triggers`)
.then((r) => (r.ok ? r.json() : null))
.then((json) => {
if (!json) return
// Map API format (nested trigger object) to flat frontend format
const raw = json.triggers || []
const mapped: ComplianceTrigger[] = raw.map((t: Record<string, unknown>, i: number) => {
const inner = (t.trigger || t) as Record<string, unknown>
const reg = (inner.regulation || '') as string
return {
id: (t.hazard_id as string) || `trigger-${i}`,
regulation: reg.split(' ')[0] || reg,
article: reg.includes(' ') ? reg.split(' ').slice(1).join(' ') : '',
title: (inner.action_de || inner.trigger_cond_de || '') as string,
severity: ((inner.severity || 'medium') as string) as 'high' | 'medium' | 'low',
reason: (inner.trigger_cond_de || '') as string,
affected_hazard_count: 1,
module_path: (inner.module_link || '/sdk') as string,
module_label: ((inner.module || 'Modul') as string).toUpperCase(),
}
})
setData({ triggers: mapped, total: mapped.length })
})
.catch(() => {})
.finally(() => setLoading(false))
}, [projectId])
if (loading) return null
if (!data || data.triggers.length === 0) return null
const triggers = data.triggers
const activeRegulations = new Set(triggers.map((t) => t.regulation))
function toggleExpanded(id: string) {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-red-200 dark:border-red-800">
{/* Header */}
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center justify-between p-6 text-left"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-50 dark:bg-red-900/30 rounded-lg flex items-center justify-center">
<WarningIcon className="w-5 h-5 text-red-600" />
</div>
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
{triggers.length} Compliance-Hinweise erkannt
</h2>
<p className="text-xs text-gray-500">
Basierend auf den identifizierten Gefaehrdungen bestehen rechtliche Implikationen
</p>
</div>
</div>
<div className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0">
<ChevronIcon open={!collapsed} />
</div>
</button>
{!collapsed && (
<div className="px-6 pb-6 space-y-4">
{/* Regulation summary badges */}
<div className="flex flex-wrap gap-2">
{REGULATION_BADGES.map((reg) => {
const active = activeRegulations.has(reg.key)
return (
<span
key={reg.key}
className={`px-2.5 py-1 text-xs font-medium rounded-full border ${
active
? reg.activeColor
: 'bg-gray-50 text-gray-400 border-gray-200 dark:bg-gray-700 dark:text-gray-500 dark:border-gray-600'
}`}
>
{reg.label}
</span>
)
})}
</div>
{/* Trigger list */}
<div className="space-y-2">
{triggers.map((trigger) => {
const sev = SEVERITY_CONFIG[trigger.severity] || SEVERITY_CONFIG.low
const isOpen = expandedIds.has(trigger.id)
return (
<div key={trigger.id} className={`rounded-lg border ${sev.border} ${sev.bg} overflow-hidden`}>
{/* Trigger header row */}
<button
onClick={() => toggleExpanded(trigger.id)}
className="w-full flex items-center gap-3 px-4 py-3 text-left"
>
<ChevronIcon open={isOpen} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-gray-900 dark:text-white">
{trigger.regulation} {trigger.article} {trigger.title}
</span>
</div>
<span className={`px-2 py-0.5 text-xs font-bold rounded ${sev.badge}`}>
{SEVERITY_LABELS[trigger.severity] || trigger.severity}
</span>
</button>
{/* Expanded detail */}
{isOpen && (
<div className="px-4 pb-4 pt-0 ml-7 space-y-2">
<p className="text-xs text-gray-700 dark:text-gray-300">
<span className="font-medium">Grund:</span> {trigger.reason}
</p>
{trigger.affected_hazard_count != null && trigger.affected_hazard_count > 0 && (
<p className="text-xs text-gray-500">
Betroffene Gefaehrdungen: {trigger.affected_hazard_count}
</p>
)}
<Link
href={trigger.module_path}
className={`inline-flex items-center gap-1.5 text-xs font-medium ${sev.text} hover:underline`}
>
{trigger.module_label} oeffnen
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</Link>
</div>
)}
</div>
)
})}
</div>
{/* Disclaimer */}
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-xs text-amber-800 dark:text-amber-300">
<strong>Hinweis:</strong> Diese Compliance-Hinweise werden automatisch aus den
Gefaehrdungen und Klassifikationen abgeleitet. Der CE-Fachmann muss die
regulatorischen Anforderungen im jeweiligen Modul verifizieren.
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,182 @@
'use client'
import { useState } from 'react'
interface DeltaResult {
added_patterns?: Array<{ pattern_name: string; hazard_cats: string[] }>
removed_patterns?: Array<{ pattern_name: string; hazard_cats: string[] }>
added_hazards?: Array<{ name: string; category: string }>
removed_hazards?: Array<{ name: string; category: string }>
added_measures?: Array<{ id: string; name: string }>
removed_measures?: Array<{ id: string; name: string }>
}
interface DeltaPreviewModalProps {
projectId: string
currentInput: {
component_library_ids: string[]
energy_source_ids: string[]
operational_states?: string[]
human_roles?: string[]
}
proposedInput: {
component_library_ids: string[]
energy_source_ids: string[]
operational_states?: string[]
human_roles?: string[]
}
onClose: () => void
onApply: () => void
changeDescription: string
}
export function DeltaPreviewModal({
projectId,
currentInput,
proposedInput,
onClose,
onApply,
changeDescription,
}: DeltaPreviewModalProps) {
const [result, setResult] = useState<DeltaResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// Auto-run delta analysis on mount
useState(() => {
runDelta()
})
async function runDelta() {
setLoading(true)
setError('')
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/delta-analysis`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current: currentInput, proposed: proposedInput }),
})
if (!res.ok) {
setError('Delta-Analyse fehlgeschlagen')
return
}
setResult(await res.json())
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setLoading(false)
}
}
const addedP = result?.added_patterns?.length || 0
const removedP = result?.removed_patterns?.length || 0
const addedH = result?.added_hazards?.length || 0
const removedH = result?.removed_hazards?.length || 0
const addedM = result?.added_measures?.length || 0
const removedM = result?.removed_measures?.length || 0
const hasChanges = addedP + removedP + addedH + removedH + addedM + removedM > 0
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[80vh] overflow-y-auto">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Delta-Vorschau</h2>
<p className="text-xs text-gray-500 mt-0.5">{changeDescription}</p>
</div>
{/* Content */}
<div className="px-6 py-4">
{loading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600" />
<span className="ml-3 text-sm text-gray-500">Berechne Auswirkungen...</span>
</div>
)}
{error && (
<div className="bg-red-50 text-red-700 rounded-lg p-3 text-sm">{error}</div>
)}
{result && !loading && (
<div className="space-y-4">
{/* Summary Grid */}
<div className="grid grid-cols-3 gap-3">
<DeltaStat label="Patterns" added={addedP} removed={removedP} />
<DeltaStat label="Gefaehrdungen" added={addedH} removed={removedH} />
<DeltaStat label="Massnahmen" added={addedM} removed={removedM} />
</div>
{!hasChanges && (
<p className="text-sm text-gray-400 italic text-center py-2">
Keine Auswirkungen erkannt die Aenderung beeinflusst keine Patterns.
</p>
)}
{/* Added Hazards */}
{addedH > 0 && (
<div>
<h3 className="text-xs font-semibold text-green-700 mb-1">+ Neue Gefaehrdungen</h3>
<ul className="space-y-0.5 max-h-32 overflow-y-auto">
{result!.added_hazards!.slice(0, 15).map((h, i) => (
<li key={i} className="text-xs text-gray-600 flex items-center gap-1">
<span className="text-green-500 flex-shrink-0">+</span>
<span className="truncate">{h.name || h.category}</span>
</li>
))}
{addedH > 15 && <li className="text-xs text-gray-400">... und {addedH - 15} weitere</li>}
</ul>
</div>
)}
{/* Removed Hazards */}
{removedH > 0 && (
<div>
<h3 className="text-xs font-semibold text-red-700 mb-1">- Entfallene Gefaehrdungen</h3>
<ul className="space-y-0.5 max-h-32 overflow-y-auto">
{result!.removed_hazards!.slice(0, 10).map((h, i) => (
<li key={i} className="text-xs text-gray-600 flex items-center gap-1">
<span className="text-red-500 flex-shrink-0">-</span>
<span className="truncate">{h.name || h.category}</span>
</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition-colors"
>
Abbrechen
</button>
<button
onClick={onApply}
disabled={loading}
className="px-5 py-2 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
Aenderung uebernehmen
</button>
</div>
</div>
</div>
)
}
function DeltaStat({ label, added, removed }: { label: string; added: number; removed: number }) {
return (
<div className="text-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="text-xs text-gray-500 mb-1">{label}</div>
<div className="flex items-center justify-center gap-2">
{added > 0 && <span className="text-sm font-bold text-green-600">+{added}</span>}
{removed > 0 && <span className="text-sm font-bold text-red-600">-{removed}</span>}
{added === 0 && removed === 0 && <span className="text-sm text-gray-400">0</span>}
</div>
</div>
)
}
@@ -0,0 +1,258 @@
'use client'
import React, { useState, useEffect, useRef, useCallback } from 'react'
import Link from 'next/link'
import { usePathname, useParams } from 'next/navigation'
interface CEStep {
step: number
label: string
href: string | null
external?: boolean
sameAs?: number
note?: string
}
const CE_STEPS: CEStep[] = [
{ step: 3, label: 'Grenzen & Verwendung', href: '/interview' },
{ step: 4, label: 'Normenrecherche', href: null, external: true },
{ step: 5, label: 'Komponenten', href: '/components' },
{ step: 6, label: 'Gefaehrdungen', href: '/hazards' },
{ step: 7, label: 'Risikobewertung', href: '/hazards', sameAs: 6 },
{ step: 8, label: 'Massnahmen', href: '/mitigations' },
{ step: 9, label: 'Nachweise', href: '/evidence' },
{ step: 10, label: 'Restrisiko', href: '/hazards', note: 'Reassessment' },
{ step: 11, label: 'Verifikation', href: '/verification' },
{ step: 14, label: 'CE-Akte', href: '/tech-file' },
]
function getNavigableSteps(basePath: string): CEStep[] {
return CE_STEPS.filter((s) => s.href !== null && !s.external)
}
export default function IACEFlowFAB() {
const [isOpen, setIsOpen] = useState(false)
const panelRef = useRef<HTMLDivElement>(null)
const fabRef = useRef<HTMLButtonElement>(null)
const pathname = usePathname()
const params = useParams()
const projectId = params?.projectId as string
const basePath = `/sdk/iace/${projectId}`
const activeStepIndex = CE_STEPS.findIndex((s) => {
if (!s.href) return false
return pathname.startsWith(`${basePath}${s.href}`)
})
const navigableSteps = getNavigableSteps(basePath)
const currentNavIndex = navigableSteps.findIndex((s) => {
if (!s.href) return false
return pathname.startsWith(`${basePath}${s.href}`)
})
const completedCount = CE_STEPS.filter((s) => s.href && !s.external).length
const totalSteps = CE_STEPS.length
const handleClose = useCallback(() => setIsOpen(false), [])
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose()
}
function onClickOutside(e: MouseEvent) {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
fabRef.current &&
!fabRef.current.contains(e.target as Node)
) {
handleClose()
}
}
if (isOpen) {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('mousedown', onClickOutside)
}
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('mousedown', onClickOutside)
}
}, [isOpen, handleClose])
const goPrev = () => {
if (currentNavIndex > 0) {
const prev = navigableSteps[currentNavIndex - 1]
if (prev.href) window.location.href = `${basePath}${prev.href}`
}
}
const goNext = () => {
if (currentNavIndex < navigableSteps.length - 1) {
const next = navigableSteps[currentNavIndex + 1]
if (next.href) window.location.href = `${basePath}${next.href}`
}
}
return (
<div className="fixed bottom-6 right-6 z-50 flex flex-col items-end pointer-events-none">
{/* Expanded Panel */}
<div
ref={panelRef}
className={`mb-3 w-[300px] max-h-[70vh] overflow-y-auto bg-white dark:bg-gray-800 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 transition-all duration-200 origin-bottom-right ${
isOpen
? 'opacity-100 scale-100 translate-y-0 pointer-events-auto'
: 'opacity-0 scale-95 translate-y-2 pointer-events-none'
}`}
>
{/* Header */}
<div className="sticky top-0 bg-white dark:bg-gray-800 px-4 py-3 border-b border-gray-100 dark:border-gray-700">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
CE-Prozessschritte
</h3>
<p className="text-xs text-gray-500 mt-0.5">
{completedCount}/{totalSteps} Schritte im Tool
</p>
</div>
{/* Steps */}
<div className="py-2 px-2">
{CE_STEPS.map((step, idx) => {
const isActive = idx === activeStepIndex
const isExternal = step.external || step.href === null
const fullHref = step.href ? `${basePath}${step.href}` : null
const rowContent = (
<div
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
isActive
? 'bg-purple-50 dark:bg-purple-900/40'
: isExternal
? 'opacity-50 cursor-default'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`}
>
{/* Step number circle */}
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
isActive
? 'bg-purple-600 text-white'
: isExternal
? 'bg-gray-200 dark:bg-gray-600 text-gray-400 dark:text-gray-500'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
>
{isActive ? (
<span className="w-2 h-2 rounded-full bg-white" />
) : !isExternal ? (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : (
step.step
)}
</div>
{/* Label */}
<div className="flex-1 min-w-0">
<span
className={`block truncate font-medium ${
isActive
? 'text-purple-700 dark:text-purple-300'
: isExternal
? 'text-gray-400 dark:text-gray-500'
: 'text-gray-700 dark:text-gray-200'
}`}
>
{step.label}
</span>
{(step.note || isExternal) && (
<span className="text-[10px] text-gray-400">
{step.note || '(extern)'}
</span>
)}
</div>
{/* Step badge */}
<span className="text-[10px] text-gray-400 flex-shrink-0">
#{step.step}
</span>
</div>
)
if (fullHref && !isExternal) {
return (
<Link key={idx} href={fullHref} onClick={handleClose}>
{rowContent}
</Link>
)
}
return <div key={idx}>{rowContent}</div>
})}
</div>
{/* Prev/Next navigation */}
<div className="sticky bottom-0 bg-white dark:bg-gray-800 border-t border-gray-100 dark:border-gray-700 px-4 py-2.5 flex items-center justify-between">
<button
onClick={goPrev}
disabled={currentNavIndex <= 0}
className="flex items-center gap-1 text-xs font-medium text-purple-600 hover:text-purple-700 disabled:text-gray-300 disabled:cursor-not-allowed transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck
</button>
<span className="text-[10px] text-gray-400">
{currentNavIndex >= 0 ? currentNavIndex + 1 : '-'}/{navigableSteps.length}
</span>
<button
onClick={goNext}
disabled={currentNavIndex >= navigableSteps.length - 1 || currentNavIndex < 0}
className="flex items-center gap-1 text-xs font-medium text-purple-600 hover:text-purple-700 disabled:text-gray-300 disabled:cursor-not-allowed transition-colors"
>
Weiter
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
{/* FAB Button */}
<button
ref={fabRef}
onClick={() => setIsOpen((o) => !o)}
className="pointer-events-auto w-14 h-14 rounded-full bg-gradient-to-br from-purple-600 to-indigo-600 text-white shadow-lg hover:shadow-xl hover:scale-105 active:scale-95 transition-all flex items-center justify-center"
title="CE-Prozessschritte"
>
{/* Steps/flow icon */}
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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-6 9l2 2 4-4" />
</svg>
{/* Progress ring */}
<svg className="absolute w-14 h-14" viewBox="0 0 56 56">
<circle
cx="28"
cy="28"
r="25"
fill="none"
stroke="rgba(255,255,255,0.2)"
strokeWidth="3"
/>
<circle
cx="28"
cy="28"
r="25"
fill="none"
stroke="white"
strokeWidth="3"
strokeDasharray={`${(completedCount / totalSteps) * 157} 157`}
strokeLinecap="round"
transform="rotate(-90 28 28)"
/>
</svg>
</button>
</div>
)
}
@@ -0,0 +1,195 @@
'use client'
import { useState, useEffect } from 'react'
interface NormRef {
id: string
number: string
title_de: string
norm_type: string
scope_de: string
mandatory: boolean
}
interface NormSuggestion {
norm: NormRef
reason: string
confidence: number
}
interface NormResult {
a_norms: NormSuggestion[]
b1_norms: NormSuggestion[]
b2_norms: NormSuggestion[]
c_norms: NormSuggestion[]
total: number
}
const TYPE_CONFIG: Record<string, { label: string; color: string; desc: string }> = {
a_norms: { label: 'A-Normen', color: 'border-red-200 bg-red-50 text-red-800', desc: 'Grundnormen (immer anwendbar)' },
b1_norms: { label: 'B1-Normen', color: 'border-blue-200 bg-blue-50 text-blue-800', desc: 'Sicherheitsgrundnormen' },
b2_norms: { label: 'B2-Normen', color: 'border-green-200 bg-green-50 text-green-800', desc: 'Sicherheitsfachgrundnormen' },
c_norms: { label: 'C-Normen', color: 'border-purple-200 bg-purple-50 text-purple-800', desc: 'Maschinenspezifische Normen' },
}
export function SuggestedNorms({ projectId }: { projectId: string }) {
const [data, setData] = useState<NormResult | null>(null)
const [loading, setLoading] = useState(true)
const [collapsed, setCollapsed] = useState(false)
const [customNorms, setCustomNorms] = useState<Array<{ number: string; title: string }>>([])
const [customNormNumber, setCustomNormNumber] = useState('')
const [customNormTitle, setCustomNormTitle] = useState('')
useEffect(() => {
fetch(`/api/sdk/v1/iace/projects/${projectId}/suggested-norms`)
.then((r) => r.ok ? r.json() : null)
.then((json) => {
if (json?.suggestions) setData(json.suggestions)
else if (json?.a_norms !== undefined) setData(json)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [projectId])
if (loading) return null
if (!data || data.total === 0) return null
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<button
onClick={() => setCollapsed(!collapsed)}
className="w-full flex items-center justify-between p-6 text-left"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Normenrecherche {data.total} relevante Normen
</h2>
<p className="text-xs text-gray-500">
Automatisch ermittelt aus Maschinentyp, Gefaehrdungen und Komponenten
</p>
</div>
</div>
<div className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0">
<svg className={`w-5 h-5 text-gray-400 transition-transform ${collapsed ? '' : 'rotate-180'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{!collapsed && (
<div className="px-6 pb-6 space-y-4">
{/* Legend */}
<div className="flex flex-wrap gap-2 text-xs">
{Object.entries(TYPE_CONFIG).map(([key, cfg]) => (
<span key={key} className={`px-2 py-0.5 rounded border ${cfg.color}`}>{cfg.label}: {cfg.desc}</span>
))}
</div>
{/* Norm groups */}
{(['a_norms', 'b1_norms', 'b2_norms', 'c_norms'] as const).map((type) => {
const norms = data[type]
if (!norms || norms.length === 0) return null
const cfg = TYPE_CONFIG[type]
return (
<div key={type}>
<h3 className={`text-xs font-semibold px-2 py-1 rounded inline-block mb-2 border ${cfg.color}`}>
{cfg.label} ({norms.length})
</h3>
<div className="space-y-2">
{norms.map((s) => (
<div key={s.norm.id} className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-100 dark:border-gray-600">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono font-semibold text-gray-900 dark:text-white">
{s.norm.number}
</span>
{s.norm.mandatory && (
<span className="px-1.5 py-0.5 text-xs font-medium bg-red-100 text-red-700 rounded">
Pflicht
</span>
)}
<span className="px-1.5 py-0.5 text-xs bg-gray-200 text-gray-600 rounded">
{Math.round(s.confidence * 100)}%
</span>
</div>
<p className="text-xs text-gray-700 dark:text-gray-300 mt-0.5">{s.norm.title_de}</p>
<p className="text-xs text-gray-500 mt-1">{s.norm.scope_de}</p>
<p className="text-xs text-amber-600 mt-1">
Grund: {s.reason}
</p>
</div>
</div>
))}
</div>
</div>
)
})}
{/* Add custom norm */}
<div className="p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Weitere Norm ergaenzen</p>
<div className="flex gap-2">
<input
type="text" placeholder="z.B. ISO 13857:2019"
value={customNormNumber} onChange={(e) => setCustomNormNumber(e.target.value)}
className="flex-1 px-3 py-1.5 text-xs border border-gray-300 rounded-lg focus:ring-1 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<input
type="text" placeholder="Titel (optional)"
value={customNormTitle} onChange={(e) => setCustomNormTitle(e.target.value)}
className="flex-1 px-3 py-1.5 text-xs border border-gray-300 rounded-lg focus:ring-1 focus:ring-purple-400 dark:bg-gray-700 dark:border-gray-600 dark:text-white"
/>
<button
onClick={() => {
if (customNormNumber.trim()) {
setCustomNorms((prev) => [...prev, { number: customNormNumber.trim(), title: customNormTitle.trim() }])
setCustomNormNumber('')
setCustomNormTitle('')
}
}}
disabled={!customNormNumber.trim()}
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
+ Hinzufuegen
</button>
</div>
{customNorms.length > 0 && (
<div className="mt-2 space-y-1">
{customNorms.map((cn, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="font-mono font-semibold text-gray-800 dark:text-gray-200">{cn.number}</span>
{cn.title && <span className="text-gray-500"> {cn.title}</span>}
<span className="px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded text-xs">Manuell</span>
<button onClick={() => setCustomNorms((prev) => prev.filter((_, j) => j !== i))} className="text-red-400 hover:text-red-600">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
))}
</div>
)}
</div>
{/* Pflicht-Erklärung + Disclaimer */}
<div className="space-y-2 text-xs">
<div className="p-3 rounded-lg bg-red-50 border border-red-200 text-red-800">
<strong>Pflicht</strong> bedeutet: Diese Norm ist fuer diesen Maschinentyp typischerweise zwingend anzuwenden
(z.B. ISO 12100 fuer alle Maschinen, EN 692 fuer mechanische Pressen). Die Anwendung harmonisierter Normen
erzeugt eine Konformitaetsvermutung.
</div>
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800">
<strong>Hinweis:</strong> Diese Normenvorschlaege basieren auf dem Maschinentyp und den identifizierten
Gefaehrdungen. Der CE-Fachmann muss die Anwendbarkeit pruefen und ggf. weitere Normen ueber das Feld oben ergaenzen.
Normtexte muessen separat beschafft werden (z.B. ueber <a href="https://www.beuth.de" target="_blank" rel="noopener noreferrer" className="underline font-medium">beuth.de</a>).
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,307 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
interface VariantProject {
id: string
machine_name: string
description?: string
status: string
hazard_count?: number
parent_project_id?: string
}
interface VariantGapResponse {
base_project: { id: string; name: string; hazard_count: number; measure_count: number }
variant: { id: string; name: string; hazard_count: number; measure_count: number }
gap: { additional_hazards: number; additional_measures: number; categories_affected: string[] }
}
interface BaseProjectSummary {
hazard_count: number
component_count: number
mitigation_count: number
norms_count: number
}
interface Props {
projectId: string
parentProjectId?: string | null
parentProjectName?: string
}
function VariantBanner({ projectId, parentProjectId, parentProjectName }: { projectId: string; parentProjectId: string; parentProjectName?: string }) {
const [baseSummary, setBaseSummary] = useState<BaseProjectSummary | null>(null)
useEffect(() => {
async function loadBase() {
try {
const [projRes, riskRes] = await Promise.all([
fetch(`/api/sdk/v1/iace/projects/${parentProjectId}`),
fetch(`/api/sdk/v1/iace/projects/${parentProjectId}/risk-summary`),
])
const proj = projRes.ok ? await projRes.json() : null
const risk = riskRes.ok ? await riskRes.json() : null
const rs = risk?.risk_summary || risk || {}
setBaseSummary({
hazard_count: rs.total_hazards || rs.total || 0,
component_count: proj?.components?.length || 0,
mitigation_count: rs.total_mitigations || 0,
norms_count: 0,
})
} catch { /* ignore */ }
}
loadBase()
}, [parentProjectId])
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-purple-200 dark:border-purple-700 p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-50 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-white">Variante</p>
<p className="text-xs text-gray-500">
Diese Seite zeigt nur die <strong>varianten-spezifischen</strong> Gefaehrdungen und Massnahmen.
Die Basis-Risikobeurteilung liegt im Eltern-Projekt.
</p>
</div>
<Link
href={`/sdk/iace/${parentProjectId}`}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
>
{parentProjectName || 'Basis-Projekt'}
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</Link>
</div>
{baseSummary && (
<div className="bg-purple-50/50 dark:bg-purple-900/10 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
<p className="text-xs font-medium text-purple-700 dark:text-purple-300 mb-2">Basis-Projekt Zusammenfassung</p>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{baseSummary.hazard_count}</div>
<div className="text-xs text-gray-500">Gefaehrdungen</div>
</div>
<div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{baseSummary.mitigation_count}</div>
<div className="text-xs text-gray-500">Massnahmen</div>
</div>
<div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{baseSummary.component_count}</div>
<div className="text-xs text-gray-500">Komponenten</div>
</div>
</div>
</div>
)}
</div>
)
}
export function VariantPanel({ projectId, parentProjectId, parentProjectName }: Props) {
const [variants, setVariants] = useState<VariantProject[]>([])
const [gapMap, setGapMap] = useState<Record<string, VariantGapResponse>>({})
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const fetchVariants = useCallback(async () => {
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variants`)
if (!res.ok) {
setVariants([])
return
}
const json = await res.json()
const list: VariantProject[] = json.variants || json.projects || []
setVariants(list)
// Fetch gap analysis for this project
const gapRes = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variant-gap`)
if (gapRes.ok) {
const gapJson = await gapRes.json()
const gaps: Record<string, VariantGapResponse> = {}
// Could be a single gap or array — handle both
if (Array.isArray(gapJson)) {
for (const g of gapJson) {
gaps[g.variant?.id] = g
}
} else if (gapJson.variant) {
gaps[gapJson.variant.id] = gapJson
}
setGapMap(gaps)
}
} catch {
setVariants([])
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => {
fetchVariants()
}, [fetchVariants])
async function handleCreate() {
if (!name.trim()) return
setCreating(true)
try {
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/variants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
machine_name: name.trim(),
description: description.trim(),
}),
})
if (res.ok) {
setName('')
setDescription('')
setShowCreate(false)
fetchVariants()
}
} catch {
// silently handle
} finally {
setCreating(false)
}
}
// If this project IS a variant, show link to base project + base stats
if (parentProjectId) {
return <VariantBanner projectId={projectId} parentProjectId={parentProjectId} parentProjectName={parentProjectName} />
}
if (loading) return null
if (variants.length === 0 && !showCreate) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-50 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-white">Keine Varianten</p>
<p className="text-xs text-gray-500">Erstellen Sie Varianten fuer verschiedene Betriebsarten</p>
</div>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-3 py-1.5 text-sm font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
>
+ Neue Variante
</button>
</div>
{renderCreateDialog()}
</div>
)
}
function renderCreateDialog() {
if (!showCreate) return null
return (
<div className="mt-4 p-4 border border-purple-200 dark:border-purple-700 rounded-lg bg-purple-50/50 dark:bg-purple-900/10 space-y-3">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Neue Variante erstellen</h3>
<input
type="text"
placeholder="Variantenname (z.B. Kollaborierender Betrieb)"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<textarea
placeholder="Beschreibung (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg bg-white dark:bg-gray-800 dark:border-gray-600 dark:text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<div className="flex gap-2 justify-end">
<button
onClick={() => { setShowCreate(false); setName(''); setDescription('') }}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400"
>
Abbrechen
</button>
<button
onClick={handleCreate}
disabled={creating || !name.trim()}
className="px-4 py-1.5 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{creating ? 'Erstelle...' : 'Erstellen'}
</button>
</div>
</div>
)
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-purple-50 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</div>
<div>
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">
Varianten ({variants.length})
</h2>
<p className="text-xs text-gray-500">Betriebsart-spezifische Projektversionen</p>
</div>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-3 py-1.5 text-sm font-medium text-purple-700 bg-purple-50 rounded-lg hover:bg-purple-100 dark:text-purple-300 dark:bg-purple-900/30 dark:hover:bg-purple-900/50 transition-colors"
>
+ Neue Variante
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{variants.map((v) => {
const gap = gapMap[v.id]
return (
<Link
key={v.id}
href={`/sdk/iace/${v.id}`}
className="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md hover:border-purple-300 transition-all group"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate group-hover:text-purple-700 dark:group-hover:text-purple-400">
{v.machine_name}
</p>
{v.description && (
<p className="text-xs text-gray-500 mt-0.5 line-clamp-2">{v.description}</p>
)}
</div>
<svg className="w-4 h-4 text-gray-400 group-hover:text-purple-600 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7l5 5m0 0l-5 5m5-5H6" />
</svg>
</div>
{gap && gap.gap.additional_hazards > 0 && (
<span className="inline-flex items-center mt-2 px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/50 dark:text-orange-300 rounded-full">
+{gap.gap.additional_hazards} Gefaehrdungen
</span>
)}
</Link>
)
})}
</div>
{renderCreateDialog()}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More