Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05839e36aa | |||
| 870953f579 | |||
| 1005ba0398 | |||
| fb6192d92d | |||
| 8849c396b5 | |||
| ba9558384f | |||
| 2e1e18d853 | |||
| 9bc0f321e0 | |||
| 97a52533a8 | |||
| b363c28539 | |||
| 3c12e06faf | |||
| 58234ac18b | |||
| 4642abba23 | |||
| e7f2f98da3 | |||
| 3853a0838a | |||
| 5188411828 | |||
| 45446aef16 | |||
| e19d9ca532 | |||
| a680276c86 | |||
| fa45b5793c | |||
| 7e7f31c344 | |||
| 6da36d87c2 | |||
| e50c4d659e | |||
| 9f16e6d535 | |||
| 1ff34227bf | |||
| f4374cfe8d | |||
| 7b8440191e | |||
| 510f513811 | |||
| b50c4ec940 | |||
| 090da0f71b | |||
| 13c5880f51 | |||
| 0416bb5d04 | |||
| 539bc824fd | |||
| 4c68caac4e | |||
| 254dbab566 | |||
| ef8e7e599f | |||
| 8fb2061e9b | |||
| 8d6959e8b2 | |||
| 85e82d0dfa | |||
| a349111a01 | |||
| 3ac8d0cba8 | |||
| e3ae35891f | |||
| 72761d6066 | |||
| e494cf62bb | |||
| d547e63663 | |||
| b4f90ed113 | |||
| daa47bb7ab | |||
| 6c5e086356 | |||
| 8e40155459 | |||
| b5cf25f6ab | |||
| 7c7513525e | |||
| d816cf8d3a | |||
| 8dd1581fae | |||
| ea8353f1a0 | |||
| d80cb9c8e4 | |||
| cb607bf228 | |||
| d7b287889e | |||
| d4b7943d54 | |||
| 47ec792acf | |||
| f3e44cf59f | |||
| 3fade26d89 | |||
| 797ed667a2 | |||
| a3f7fb93f4 | |||
| f967480cd9 | |||
| 275bdf9848 | |||
| a18ef16378 | |||
| 5c0ca803b0 | |||
| f960bd052a | |||
| b22351fc6e | |||
| a846bd8910 | |||
| a970c28168 | |||
| 48146cddaf | |||
| 298c95731a | |||
| 4e63a6050d | |||
| 9395a0084a | |||
| 74dddbfa0f | |||
| 129849aa21 | |||
| b997b4a475 | |||
| 7fc43a3f1f | |||
| 5d138f265b | |||
| 0b7e14f202 | |||
| 2fb417c784 | |||
| 15a1879803 | |||
| a1272390ff | |||
| e8b5c90a49 | |||
| 6af35dbf5f | |||
| bb2ebd03cd | |||
| 4834e8ad5c | |||
| 3bf0804af6 | |||
| 89ff62e534 | |||
| 11ca113318 | |||
| 340fd27a1a | |||
| e7f5bb1c33 | |||
| 4a8565f5b0 | |||
| 61c3f8fd4a | |||
| 199f7835a7 | |||
| 9510ce0ff9 | |||
| fbeefa8fce | |||
| 9bc816e55c | |||
| 9424f4ebcc | |||
| 6ed2505871 | |||
| 29f9a8fea3 | |||
| f170b07014 | |||
| c3db56ddb6 | |||
| 44acd68c96 | |||
| 9f1b7ff38b | |||
| a1f5d883cc | |||
| c3f8e19e92 | |||
| b2a28eb4cd | |||
| b06a33a5fe | |||
| 6c0e76f96d | |||
| 0106f3b5b6 | |||
| b175ad2594 | |||
| 4c43253a53 | |||
| 0f1fae61a6 | |||
| 711b9b3146 | |||
| d0dc284cd5 | |||
| 24fb1e14e0 | |||
| 6aa753146f | |||
| acd2d5f944 | |||
| 2a6f526c88 | |||
| 1988274420 | |||
| cb5aa2949b | |||
| 41fd7e36d1 | |||
| f7483f5724 | |||
| cfc130a544 | |||
| 0ccc6c4047 | |||
| 5ff65b3402 | |||
| 290254056e | |||
| 7dccdf4695 | |||
| 8e0645481a | |||
| 918a9d8092 | |||
| 0c0dd4e3a6 | |||
| f528b8e7a9 | |||
| 98243044ca | |||
| fcef07aa16 | |||
| 0c7c70b1b1 | |||
| 16957cadfd | |||
| 3dfe0aa646 | |||
| 2e0f13b22c | |||
| 9a6c297cd6 | |||
| bb0c7d208c | |||
| 7b20e2b006 | |||
| 4ff06eca17 | |||
| 1c2fdf981d | |||
| a2205abea1 | |||
| ef7742cd44 | |||
| 3fe0fc853c | |||
| 8f2cc3b93b | |||
| 753b8f32c7 | |||
| 390d32a9cb | |||
| fc8b6445f3 | |||
| 717c31547a | |||
| 55a2cd4a3d | |||
| 6fcf7c13d7 | |||
| b1300ade3e | |||
| 5d53acf5dc | |||
| f8fd329059 | |||
| 1ac716261c | |||
| 01bf1463b8 | |||
| cc6f1489a3 | |||
| b47d351c73 | |||
| 5231490ccc | |||
| 824b1be6a4 | |||
| 062e827801 | |||
| f404226d6e | |||
| 8dfab4ba14 | |||
| 5c1a514b52 | |||
| e091bbc855 | |||
| ff4c359d46 | |||
| f169b13dbf | |||
| 42d0c7b1fc | |||
| 4fcb842a92 | |||
| 38d3d24121 | |||
| dd64e33e88 | |||
| 2f8269d115 | |||
| 532febe35c | |||
| 0a0863f31c | |||
| d892ad161f | |||
| 17153ccbe8 | |||
| 352d7112c9 | |||
| 0957254547 | |||
| f17608a956 | |||
| ce3df9f080 | |||
| 2da39e035d | |||
| 1989c410a9 | |||
| c55a6ab995 | |||
| bc75b4455d |
@@ -184,6 +184,29 @@ jobs:
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-gateway:${SHORT_SHA}
|
||||
|
||||
build-dsms-node:
|
||||
runs-on: docker
|
||||
container: docker:27-cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
apk add --no-cache git
|
||||
git clone --depth 1 --branch ${GITHUB_REF_NAME} ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||
- name: Login
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login registry.meghsakha.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||
- name: Build + push
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
docker build --platform linux/amd64 \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-dsms-node:latest \
|
||||
-t registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA} \
|
||||
dsms-node/
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:latest
|
||||
docker push registry.meghsakha.com/breakpilot/compliance-dsms-node:${SHORT_SHA}
|
||||
|
||||
# ── orca redeploy (only after all builds succeed) ─────────────────────────
|
||||
|
||||
trigger-orca:
|
||||
@@ -197,6 +220,7 @@ jobs:
|
||||
- build-tts
|
||||
- build-document-crawler
|
||||
- build-dsms-gateway
|
||||
- build-dsms-node
|
||||
steps:
|
||||
- name: Checkout (for SHA)
|
||||
run: |
|
||||
|
||||
@@ -98,6 +98,146 @@ 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 widerspruechlichen Rechtslagen: Beide Positionen darstellen und DSB-Konsultation empfehlen
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Agent Analyze API Proxy
|
||||
* POST /api/sdk/v1/agent/analyze → backend-compliance /api/compliance/agent/analyze
|
||||
*/
|
||||
|
||||
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/analyze`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-Id': '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
|
||||
'X-User-Id': '00000000-0000-0000-0000-000000000001',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(120000), // 2 min — LLM can be slow
|
||||
})
|
||||
|
||||
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) {
|
||||
console.error('Agent analyze proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verbindung zum Backend fehlgeschlagen' },
|
||||
{ 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,70 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
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(30000), // 30s — just needs to start the job
|
||||
})
|
||||
|
||||
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) {
|
||||
console.error('Agent scan proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ 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,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`)
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to update status' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch registrations' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/ai-registration`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to create registration' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000001'
|
||||
|
||||
function buildUrl(request: NextRequest, params: { path?: string[] }) {
|
||||
const subPath = params.path?.join('/') || ''
|
||||
const { searchParams } = new URL(request.url)
|
||||
const qs = searchParams.toString()
|
||||
return `${SDK_URL}/sdk/v1/maximizer/${subPath}${qs ? `?${qs}` : ''}`
|
||||
}
|
||||
|
||||
function forwardHeaders(request: NextRequest): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
headers['X-Tenant-ID'] = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID
|
||||
headers['X-User-ID'] = request.headers.get('X-User-ID') || DEFAULT_USER_ID
|
||||
return headers
|
||||
}
|
||||
|
||||
async function proxy(request: NextRequest, params: { path?: string[] }, method: string) {
|
||||
try {
|
||||
const url = buildUrl(request, params)
|
||||
const init: RequestInit = { method, headers: forwardHeaders(request) }
|
||||
if (method !== 'GET' && method !== 'DELETE') {
|
||||
init.body = await request.text()
|
||||
}
|
||||
const response = await fetch(url, init)
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: 'Maximizer backend error', details: errorText }, { status: response.status })
|
||||
}
|
||||
if (response.status === 204) return new NextResponse(null, { status: 204 })
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Maximizer proxy error:', error)
|
||||
return NextResponse.json({ error: 'Failed to connect to Maximizer backend' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'GET')
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'POST')
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { path?: string[] } }) {
|
||||
return proxy(request, params, 'DELETE')
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const endpoint = searchParams.get('endpoint') || 'controls'
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
let path: string
|
||||
switch (endpoint) {
|
||||
case 'controls':
|
||||
const domain = searchParams.get('domain') || ''
|
||||
path = `/sdk/v1/payment-compliance/controls${domain ? `?domain=${domain}` : ''}`
|
||||
break
|
||||
case 'assessments':
|
||||
path = '/sdk/v1/payment-compliance/assessments'
|
||||
break
|
||||
default:
|
||||
path = '/sdk/v1/payment-compliance/controls'
|
||||
}
|
||||
|
||||
const resp = await fetch(`${SDK_URL}${path}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to fetch' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const body = await request.json()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/assessments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Tenant-ID': tenantId },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to create' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}`)
|
||||
return NextResponse.json(await resp.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const action = searchParams.get('action') || 'extract'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/${id}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
})
|
||||
return NextResponse.json(await resp.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const tenantId = request.headers.get('x-tenant-id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
const formData = await request.formData()
|
||||
const resp = await fetch(`${SDK_URL}/sdk/v1/payment-compliance/tender/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
body: formData,
|
||||
})
|
||||
return NextResponse.json(await resp.json(), { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Upload failed' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const qs = searchParams.toString()
|
||||
const url = `${SDK_URL}/sdk/v1/regulatory-news${qs ? `?${qs}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json({ error: 'SDK error' }, { status: response.status })
|
||||
}
|
||||
return NextResponse.json(await response.json())
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Connection failed' }, { status: 503 })
|
||||
}
|
||||
}
|
||||
@@ -92,11 +92,17 @@ class PostgreSQLStateStore implements StateStore {
|
||||
private pool: Pool
|
||||
|
||||
constructor(connectionString: string) {
|
||||
// Strip sslmode from URL — pg driver overrides our ssl config if it's in the URL.
|
||||
// We handle SSL ourselves via the ssl option below.
|
||||
const cleanUrl = connectionString.replace(/[?&]sslmode=[^&]*/g, '').replace(/\?$/, '')
|
||||
const needsSsl = connectionString.includes('sslmode=require') || connectionString.includes('sslmode=verify')
|
||||
this.pool = new Pool({
|
||||
connectionString,
|
||||
connectionString: cleanUrl,
|
||||
max: 5,
|
||||
// Set search_path for compliance schema
|
||||
options: '-c search_path=compliance,core,public',
|
||||
// Accept self-signed certificates (Hetzner PostgreSQL)
|
||||
ssl: needsSsl ? { rejectUnauthorized: false } : false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: POST /api/sdk/v1/ucca/assess-enriched → Go Backend POST /sdk/v1/ucca/assess-enriched
|
||||
* Accepts { intake, company_profile? } and returns enriched assessment with obligations + hints.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
const response = await fetch(`${SDK_URL}/sdk/v1/ucca/assess-enriched`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('UCCA assess-enriched error:', errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'UCCA backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Failed to call UCCA assess-enriched:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to UCCA backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/ucca/assessments/[id] → Go Backend GET /sdk/v1/ucca/assessments/:id
|
||||
@@ -16,9 +17,7 @@ export async function GET(
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -56,9 +55,7 @@ export async function PUT(
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
@@ -96,9 +93,7 @@ export async function DELETE(
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_URL = process.env.SDK_URL || 'http://ai-compliance-sdk:8090'
|
||||
const DEFAULT_TENANT_ID = process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
|
||||
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/ucca/assessments → Go Backend GET /sdk/v1/ucca/assessments
|
||||
@@ -22,9 +23,7 @@ export async function GET(request: NextRequest) {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(request.headers.get('X-Tenant-ID') && {
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') as string,
|
||||
}),
|
||||
'X-Tenant-ID': request.headers.get('X-Tenant-ID') || DEFAULT_TENANT_ID,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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: /api/sdk/v1/ucca/decision-tree/... → Go Backend /sdk/v1/ucca/decision-tree/...
|
||||
*/
|
||||
async function proxyRequest(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||
const { path } = await params
|
||||
const subPath = path ? path.join('/') : ''
|
||||
const search = request.nextUrl.search || ''
|
||||
const targetUrl = `${SDK_URL}/sdk/v1/ucca/decision-tree/${subPath}${search}`
|
||||
|
||||
const tenantID = request.headers.get('X-Tenant-ID') || DEFAULT_TENANT
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'X-Tenant-ID': tenantID,
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (request.method === 'POST' || request.method === 'PUT' || request.method === 'PATCH') {
|
||||
const body = await request.json()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
fetchOptions.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Decision tree proxy error [${request.method} ${subPath}]:`, errorText)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', details: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data, { status: response.status })
|
||||
} catch (error) {
|
||||
console.error('Decision tree proxy connection error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to AI compliance backend' },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxyRequest
|
||||
export const POST = proxyRequest
|
||||
export const DELETE = proxyRequest
|
||||
@@ -0,0 +1,36 @@
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
import { OptimizerUpsellCard } from '@/components/sdk/compliance-optimizer/OptimizerUpsellCard'
|
||||
|
||||
interface Props {
|
||||
result: unknown
|
||||
@@ -35,6 +36,13 @@ export function ResultView({ result, onGoToAssessment, onGoToOverview }: Props)
|
||||
{r.result && (
|
||||
<AssessmentResultCard result={r.result as unknown as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
)}
|
||||
{r.result && r.assessment?.id && (
|
||||
<OptimizerUpsellCard
|
||||
feasibility={(r.result as { feasibility?: string }).feasibility || 'YES'}
|
||||
assessmentId={r.assessment.id}
|
||||
riskScore={(r.result as { risk_score?: number }).risk_score}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,116 @@ export interface AdvisoryForm {
|
||||
custom_data_types: string[]
|
||||
purposes: string[]
|
||||
automation: string
|
||||
// BetrVG / works council
|
||||
employee_monitoring: boolean
|
||||
hr_decision_support: boolean
|
||||
works_council_consulted: boolean
|
||||
// Domain-specific contexts (Annex III)
|
||||
hr_automated_screening: boolean
|
||||
hr_automated_rejection: boolean
|
||||
hr_candidate_ranking: boolean
|
||||
hr_bias_audits: boolean
|
||||
hr_agg_visible: boolean
|
||||
hr_human_review: boolean
|
||||
hr_performance_eval: boolean
|
||||
edu_grade_influence: boolean
|
||||
edu_exam_evaluation: boolean
|
||||
edu_student_selection: boolean
|
||||
edu_minors: boolean
|
||||
edu_teacher_review: boolean
|
||||
hc_diagnosis: boolean
|
||||
hc_treatment: boolean
|
||||
hc_triage: boolean
|
||||
hc_patient_data: boolean
|
||||
hc_medical_device: boolean
|
||||
hc_clinical_validation: boolean
|
||||
// Legal
|
||||
leg_legal_advice: boolean
|
||||
leg_court_prediction: boolean
|
||||
leg_client_confidential: boolean
|
||||
// Public Sector
|
||||
pub_admin_decision: boolean
|
||||
pub_benefit_allocation: boolean
|
||||
pub_transparency: boolean
|
||||
// Critical Infrastructure
|
||||
crit_grid_control: boolean
|
||||
crit_safety_critical: boolean
|
||||
crit_redundancy: boolean
|
||||
// Automotive
|
||||
auto_autonomous: boolean
|
||||
auto_safety: boolean
|
||||
auto_functional_safety: boolean
|
||||
// Retail
|
||||
ret_pricing: boolean
|
||||
ret_profiling: boolean
|
||||
ret_credit_scoring: boolean
|
||||
ret_dark_patterns: boolean
|
||||
// IT Security
|
||||
its_surveillance: boolean
|
||||
its_threat_detection: boolean
|
||||
its_data_retention: boolean
|
||||
// Logistics
|
||||
log_driver_tracking: boolean
|
||||
log_workload_scoring: boolean
|
||||
// Construction
|
||||
con_tenant_screening: boolean
|
||||
con_worker_safety: boolean
|
||||
// Marketing
|
||||
mkt_deepfake: boolean
|
||||
mkt_minors: boolean
|
||||
mkt_targeting: boolean
|
||||
mkt_labeled: boolean
|
||||
// Manufacturing
|
||||
mfg_machine_safety: boolean
|
||||
mfg_ce_required: boolean
|
||||
mfg_validated: boolean
|
||||
// Agriculture
|
||||
agr_pesticide: boolean
|
||||
agr_animal_welfare: boolean
|
||||
agr_environmental: boolean
|
||||
// Social Services
|
||||
soc_vulnerable: boolean
|
||||
soc_benefit: boolean
|
||||
soc_case_mgmt: boolean
|
||||
// Hospitality
|
||||
hos_guest_profiling: boolean
|
||||
hos_dynamic_pricing: boolean
|
||||
hos_review_manipulation: boolean
|
||||
// Insurance
|
||||
ins_risk_class: boolean
|
||||
ins_claims: boolean
|
||||
ins_premium: boolean
|
||||
ins_fraud: boolean
|
||||
// Investment
|
||||
inv_algo_trading: boolean
|
||||
inv_advice: boolean
|
||||
inv_robo: boolean
|
||||
// Defense
|
||||
def_dual_use: boolean
|
||||
def_export: boolean
|
||||
def_classified: boolean
|
||||
// Supply Chain
|
||||
sch_supplier: boolean
|
||||
sch_human_rights: boolean
|
||||
sch_environmental: boolean
|
||||
// Facility
|
||||
fac_access: boolean
|
||||
fac_occupancy: boolean
|
||||
fac_energy: boolean
|
||||
// Sports
|
||||
spo_athlete: boolean
|
||||
spo_fan: boolean
|
||||
spo_doping: boolean
|
||||
// Finance / Banking
|
||||
fin_credit_scoring: boolean
|
||||
fin_aml_kyc: boolean
|
||||
fin_algo_decisions: boolean
|
||||
fin_customer_profiling: boolean
|
||||
// General
|
||||
gen_affects_people: boolean
|
||||
gen_automated_decisions: boolean
|
||||
gen_sensitive_data: boolean
|
||||
// Hosting
|
||||
hosting_provider: string
|
||||
hosting_region: string
|
||||
model_usage: string[]
|
||||
|
||||
@@ -51,6 +51,71 @@ function AdvisoryBoardPageInner() {
|
||||
custom_data_types: [],
|
||||
purposes: [],
|
||||
automation: '',
|
||||
// BetrVG / works council
|
||||
employee_monitoring: false,
|
||||
hr_decision_support: false,
|
||||
works_council_consulted: false,
|
||||
// Domain-specific contexts (Annex III)
|
||||
hr_automated_screening: false,
|
||||
hr_automated_rejection: false,
|
||||
hr_candidate_ranking: false,
|
||||
hr_bias_audits: false,
|
||||
hr_agg_visible: false,
|
||||
hr_human_review: false,
|
||||
hr_performance_eval: false,
|
||||
edu_grade_influence: false,
|
||||
edu_exam_evaluation: false,
|
||||
edu_student_selection: false,
|
||||
edu_minors: false,
|
||||
edu_teacher_review: false,
|
||||
hc_diagnosis: false,
|
||||
hc_treatment: false,
|
||||
hc_triage: false,
|
||||
hc_patient_data: false,
|
||||
hc_medical_device: false,
|
||||
hc_clinical_validation: false,
|
||||
// Legal
|
||||
leg_legal_advice: false, leg_court_prediction: false, leg_client_confidential: false,
|
||||
// Public Sector
|
||||
pub_admin_decision: false, pub_benefit_allocation: false, pub_transparency: false,
|
||||
// Critical Infrastructure
|
||||
crit_grid_control: false, crit_safety_critical: false, crit_redundancy: false,
|
||||
// Automotive
|
||||
auto_autonomous: false, auto_safety: false, auto_functional_safety: false,
|
||||
// Retail
|
||||
ret_pricing: false, ret_profiling: false, ret_credit_scoring: false, ret_dark_patterns: false,
|
||||
// IT Security
|
||||
its_surveillance: false, its_threat_detection: false, its_data_retention: false,
|
||||
// Logistics
|
||||
log_driver_tracking: false, log_workload_scoring: false,
|
||||
// Construction
|
||||
con_tenant_screening: false, con_worker_safety: false,
|
||||
// Marketing
|
||||
mkt_deepfake: false, mkt_minors: false, mkt_targeting: false, mkt_labeled: false,
|
||||
// Manufacturing
|
||||
mfg_machine_safety: false, mfg_ce_required: false, mfg_validated: false,
|
||||
// Agriculture
|
||||
agr_pesticide: false, agr_animal_welfare: false, agr_environmental: false,
|
||||
// Social Services
|
||||
soc_vulnerable: false, soc_benefit: false, soc_case_mgmt: false,
|
||||
// Hospitality
|
||||
hos_guest_profiling: false, hos_dynamic_pricing: false, hos_review_manipulation: false,
|
||||
// Insurance
|
||||
ins_risk_class: false, ins_claims: false, ins_premium: false, ins_fraud: false,
|
||||
// Investment
|
||||
inv_algo_trading: false, inv_advice: false, inv_robo: false,
|
||||
// Defense
|
||||
def_dual_use: false, def_export: false, def_classified: false,
|
||||
// Supply Chain
|
||||
sch_supplier: false, sch_human_rights: false, sch_environmental: false,
|
||||
// Facility
|
||||
fac_access: false, fac_occupancy: false, fac_energy: false,
|
||||
// Sports
|
||||
spo_athlete: false, spo_fan: false, spo_doping: false,
|
||||
// Finance / Banking
|
||||
fin_credit_scoring: false, fin_aml_kyc: false, fin_algo_decisions: false, fin_customer_profiling: false,
|
||||
// General
|
||||
gen_affects_people: false, gen_automated_decisions: false, gen_sensitive_data: false,
|
||||
hosting_provider: '',
|
||||
hosting_region: '',
|
||||
model_usage: [],
|
||||
@@ -133,18 +198,164 @@ function AdvisoryBoardPageInner() {
|
||||
retention_purpose: form.retention_purpose,
|
||||
contracts_list: form.contracts,
|
||||
subprocessors: form.subprocessors,
|
||||
employee_monitoring: form.employee_monitoring,
|
||||
hr_decision_support: form.hr_decision_support,
|
||||
works_council_consulted: form.works_council_consulted,
|
||||
// Domain-specific contexts
|
||||
hr_context: ['hr', 'recruiting'].includes(form.domain) ? {
|
||||
automated_screening: form.hr_automated_screening,
|
||||
automated_rejection: form.hr_automated_rejection,
|
||||
candidate_ranking: form.hr_candidate_ranking,
|
||||
bias_audits_done: form.hr_bias_audits,
|
||||
agg_categories_visible: form.hr_agg_visible,
|
||||
human_review_enforced: form.hr_human_review,
|
||||
performance_evaluation: form.hr_performance_eval,
|
||||
} : undefined,
|
||||
education_context: ['education', 'higher_education', 'vocational_training', 'research'].includes(form.domain) ? {
|
||||
grade_influence: form.edu_grade_influence,
|
||||
exam_evaluation: form.edu_exam_evaluation,
|
||||
student_selection: form.edu_student_selection,
|
||||
minors_involved: form.edu_minors,
|
||||
teacher_review_required: form.edu_teacher_review,
|
||||
} : undefined,
|
||||
healthcare_context: ['healthcare', 'medical_devices', 'pharma', 'elderly_care'].includes(form.domain) ? {
|
||||
diagnosis_support: form.hc_diagnosis,
|
||||
treatment_recommendation: form.hc_treatment,
|
||||
triage_decision: form.hc_triage,
|
||||
patient_data_processed: form.hc_patient_data,
|
||||
medical_device: form.hc_medical_device,
|
||||
clinical_validation: form.hc_clinical_validation,
|
||||
} : undefined,
|
||||
legal_context: ['legal', 'consulting', 'tax_advisory'].includes(form.domain) ? {
|
||||
legal_advice: form.leg_legal_advice,
|
||||
court_prediction: form.leg_court_prediction,
|
||||
client_confidential: form.leg_client_confidential,
|
||||
} : undefined,
|
||||
public_sector_context: ['public_sector', 'defense', 'justice'].includes(form.domain) ? {
|
||||
admin_decision: form.pub_admin_decision,
|
||||
benefit_allocation: form.pub_benefit_allocation,
|
||||
transparency_ensured: form.pub_transparency,
|
||||
} : undefined,
|
||||
critical_infra_context: ['energy', 'utilities', 'oil_gas'].includes(form.domain) ? {
|
||||
grid_control: form.crit_grid_control,
|
||||
safety_critical: form.crit_safety_critical,
|
||||
redundancy_exists: form.crit_redundancy,
|
||||
} : undefined,
|
||||
automotive_context: ['automotive', 'aerospace'].includes(form.domain) ? {
|
||||
autonomous_driving: form.auto_autonomous,
|
||||
safety_relevant: form.auto_safety,
|
||||
functional_safety: form.auto_functional_safety,
|
||||
} : undefined,
|
||||
retail_context: ['retail', 'ecommerce', 'wholesale'].includes(form.domain) ? {
|
||||
pricing_personalized: form.ret_pricing,
|
||||
credit_scoring: form.ret_credit_scoring,
|
||||
dark_patterns: form.ret_dark_patterns,
|
||||
} : undefined,
|
||||
it_security_context: ['it_services', 'cybersecurity', 'telecom'].includes(form.domain) ? {
|
||||
employee_surveillance: form.its_surveillance,
|
||||
threat_detection: form.its_threat_detection,
|
||||
data_retention_logs: form.its_data_retention,
|
||||
} : undefined,
|
||||
logistics_context: ['logistics'].includes(form.domain) ? {
|
||||
driver_tracking: form.log_driver_tracking,
|
||||
workload_scoring: form.log_workload_scoring,
|
||||
} : undefined,
|
||||
construction_context: ['construction', 'real_estate', 'facility_management'].includes(form.domain) ? {
|
||||
tenant_screening: form.con_tenant_screening,
|
||||
worker_safety: form.con_worker_safety,
|
||||
} : undefined,
|
||||
marketing_context: ['marketing', 'media', 'entertainment'].includes(form.domain) ? {
|
||||
deepfake_content: form.mkt_deepfake,
|
||||
behavioral_targeting: form.mkt_targeting,
|
||||
minors_targeted: form.mkt_minors,
|
||||
ai_content_labeled: form.mkt_labeled,
|
||||
} : undefined,
|
||||
manufacturing_context: ['mechanical_engineering', 'electrical_engineering', 'plant_engineering', 'chemicals', 'food_beverage'].includes(form.domain) ? {
|
||||
machine_safety: form.mfg_machine_safety,
|
||||
ce_marking_required: form.mfg_ce_required,
|
||||
safety_validated: form.mfg_validated,
|
||||
} : undefined,
|
||||
agriculture_context: ['agriculture', 'forestry', 'fishing'].includes(form.domain) ? {
|
||||
pesticide_ai: form.agr_pesticide,
|
||||
animal_welfare: form.agr_animal_welfare,
|
||||
environmental_data: form.agr_environmental,
|
||||
} : undefined,
|
||||
social_services_context: ['social_services', 'nonprofit'].includes(form.domain) ? {
|
||||
vulnerable_groups: form.soc_vulnerable,
|
||||
benefit_decision: form.soc_benefit,
|
||||
case_management: form.soc_case_mgmt,
|
||||
} : undefined,
|
||||
hospitality_context: ['hospitality', 'tourism'].includes(form.domain) ? {
|
||||
guest_profiling: form.hos_guest_profiling,
|
||||
dynamic_pricing: form.hos_dynamic_pricing,
|
||||
review_manipulation: form.hos_review_manipulation,
|
||||
} : undefined,
|
||||
insurance_context: ['insurance'].includes(form.domain) ? {
|
||||
risk_classification: form.ins_risk_class,
|
||||
claims_automation: form.ins_claims,
|
||||
premium_calculation: form.ins_premium,
|
||||
fraud_detection: form.ins_fraud,
|
||||
} : undefined,
|
||||
investment_context: ['investment'].includes(form.domain) ? {
|
||||
algo_trading: form.inv_algo_trading,
|
||||
investment_advice: form.inv_advice,
|
||||
robo_advisor: form.inv_robo,
|
||||
} : undefined,
|
||||
defense_context: ['defense'].includes(form.domain) ? {
|
||||
dual_use: form.def_dual_use,
|
||||
export_controlled: form.def_export,
|
||||
classified_data: form.def_classified,
|
||||
} : undefined,
|
||||
supply_chain_context: ['textiles', 'packaging'].includes(form.domain) ? {
|
||||
supplier_monitoring: form.sch_supplier,
|
||||
human_rights_check: form.sch_human_rights,
|
||||
environmental_impact: form.sch_environmental,
|
||||
} : undefined,
|
||||
facility_context: ['facility_management'].includes(form.domain) ? {
|
||||
access_control_ai: form.fac_access,
|
||||
occupancy_tracking: form.fac_occupancy,
|
||||
energy_optimization: form.fac_energy,
|
||||
} : undefined,
|
||||
sports_context: ['sports'].includes(form.domain) ? {
|
||||
athlete_tracking: form.spo_athlete,
|
||||
fan_profiling: form.spo_fan,
|
||||
} : undefined,
|
||||
store_raw_text: true,
|
||||
// Finance/Banking and General don't need separate context structs —
|
||||
// their fields are evaluated via existing FinancialContext or generic rules
|
||||
}
|
||||
|
||||
const url = isEditMode
|
||||
? `/api/sdk/v1/ucca/assessments/${editId}`
|
||||
: '/api/sdk/v1/ucca/assess'
|
||||
: '/api/sdk/v1/ucca/assess-enriched'
|
||||
const method = isEditMode ? 'PUT' : 'POST'
|
||||
|
||||
// For new assessments, send enriched payload with company profile
|
||||
const payload = isEditMode ? intake : {
|
||||
intake,
|
||||
company_profile: sdkState.companyProfile ? {
|
||||
company_name: sdkState.companyProfile.companyName ?? '',
|
||||
legal_form: sdkState.companyProfile.legalForm ?? '',
|
||||
industry: Array.isArray(sdkState.companyProfile.industry)
|
||||
? sdkState.companyProfile.industry.join(', ')
|
||||
: (sdkState.companyProfile.industry ?? ''),
|
||||
employee_count: sdkState.companyProfile.employeeCount ?? '',
|
||||
annual_revenue: sdkState.companyProfile.annualRevenue ?? '',
|
||||
headquarters_country: sdkState.companyProfile.headquartersCountry ?? 'DE',
|
||||
is_data_controller: sdkState.companyProfile.isDataController ?? true,
|
||||
is_data_processor: sdkState.companyProfile.isDataProcessor ?? false,
|
||||
uses_ai: true,
|
||||
dpo_name: sdkState.companyProfile.dpoName ?? null,
|
||||
subject_to_nis2: false,
|
||||
subject_to_ai_act: false,
|
||||
subject_to_iso27001: false,
|
||||
} : undefined,
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(intake),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { AnalysisResult } from '../_hooks/useAgentAnalysis'
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
privacy_policy: 'DSE',
|
||||
cookie_banner: 'Cookie',
|
||||
terms_of_service: 'AGB',
|
||||
imprint: 'Impressum',
|
||||
dpa: 'AVV',
|
||||
other: 'Sonstig',
|
||||
}
|
||||
|
||||
const RISK_DOT: Record<string, string> = {
|
||||
low: 'bg-green-500',
|
||||
medium: 'bg-yellow-500',
|
||||
high: 'bg-orange-500',
|
||||
critical: 'bg-red-500',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
history: AnalysisResult[]
|
||||
onSelect: (result: AnalysisResult) => void
|
||||
}
|
||||
|
||||
export function AnalysisHistory({ history, onSelect }: Props) {
|
||||
if (history.length === 0) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Letzte Analysen</h3>
|
||||
<div className="space-y-2">
|
||||
{history.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onSelect(item)}
|
||||
className="w-full text-left p-3 bg-white border border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${RISK_DOT[item.risk_level] || 'bg-gray-400'}`} />
|
||||
<span className="text-xs font-medium text-gray-500 w-16">
|
||||
{DOC_TYPE_LABELS[item.classification] || item.classification}
|
||||
</span>
|
||||
<span className="text-sm text-gray-700 truncate flex-1">
|
||||
{new URL(item.url).hostname}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(item.analyzed_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { AnalysisResult as AnalysisResultType } from '../_hooks/useAgentAnalysis'
|
||||
|
||||
const RISK_COLORS: Record<string, { bg: string; text: string; label: string }> = {
|
||||
low: { bg: 'bg-green-100', text: 'text-green-800', label: 'Niedrig' },
|
||||
medium: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: 'Mittel' },
|
||||
high: { bg: 'bg-orange-100', text: 'text-orange-800', label: 'Hoch' },
|
||||
critical: { bg: 'bg-red-100', text: 'text-red-800', label: 'Kritisch' },
|
||||
unknown: { bg: 'bg-gray-100', text: 'text-gray-800', label: 'Unbekannt' },
|
||||
}
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
privacy_policy: 'Datenschutzerklaerung',
|
||||
cookie_banner: 'Cookie-Banner',
|
||||
terms_of_service: 'AGB',
|
||||
imprint: 'Impressum',
|
||||
dpa: 'Auftragsverarbeitung (AVV)',
|
||||
other: 'Sonstiges',
|
||||
}
|
||||
|
||||
interface Props {
|
||||
result: AnalysisResultType
|
||||
}
|
||||
|
||||
export function AnalysisResult({ result }: Props) {
|
||||
const risk = RISK_COLORS[result.risk_level] || RISK_COLORS.unknown
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{DOC_TYPE_LABELS[result.classification] || result.classification}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 truncate max-w-md">{result.url}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${risk.bg} ${risk.text}`}>
|
||||
{risk.label} ({result.risk_score}/100)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Role Assignment */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-purple-900">
|
||||
Zugewiesen an: <strong>{result.responsible_role}</strong>
|
||||
</span>
|
||||
<span className="text-xs text-purple-600 ml-auto">
|
||||
Eskalationsstufe {result.escalation_level}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{result.summary && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Zusammenfassung</h4>
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{result.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Findings */}
|
||||
{result.findings.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Findings ({result.findings.length})</h4>
|
||||
<ul className="space-y-1">
|
||||
{result.findings.map((f, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<span className="text-orange-500 mt-0.5">!</span>
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required Controls */}
|
||||
{result.required_controls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Erforderliche Massnahmen</h4>
|
||||
<ul className="space-y-1">
|
||||
{result.required_controls.map((c, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<span className="text-blue-500 mt-0.5">✓</span>
|
||||
{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Status */}
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500 pt-2 border-t">
|
||||
<span className={result.email_status === 'sent' ? 'text-green-600' : 'text-yellow-600'}>
|
||||
{result.email_status === 'sent' ? '✉ Email gesendet' : '✉ Email ausstehend'}
|
||||
</span>
|
||||
<span className="ml-auto text-xs">
|
||||
{new Date(result.analyzed_at).toLocaleString('de-DE')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
'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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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',
|
||||
}
|
||||
|
||||
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 }: { passed: boolean; skipped?: 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>
|
||||
)
|
||||
}
|
||||
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 totalOk = results.filter(r => r.completeness_pct === 100).length
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
Dokumenten-Pruefung ({results.length} Dokumente, {totalOk} vollstaendig)
|
||||
</h3>
|
||||
</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 l2Active = r.checks.filter(c => (c.level ?? 1) === 2 && !c.skipped)
|
||||
const l1Passed = l1Checks.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">{r.label}</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{l1Checks.length > 0
|
||||
? `${l1Passed}/${l1Checks.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">
|
||||
<div className="w-16 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">
|
||||
<div className="w-16 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) => (
|
||||
<div key={g.check.id}>
|
||||
{/* L1 check */}
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckIcon passed={g.check.passed} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm ${g.check.passed ? 'text-gray-700' : '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">
|
||||
"...{g.check.matched_text}..."
|
||||
</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) => (
|
||||
<div key={ch.id} className="flex items-start gap-2">
|
||||
<CheckIcon passed={ch.passed} skipped={ch.skipped} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-xs ${
|
||||
ch.skipped ? 'text-gray-400 italic'
|
||||
: ch.passed ? 'text-gray-600' : '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">
|
||||
"...{ch.matched_text}..."
|
||||
</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,290 @@
|
||||
'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 [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 }[]>(() => {
|
||||
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,
|
||||
}),
|
||||
})
|
||||
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 entry = { date: new Date().toISOString(), urls: validEntries.length, findings: pollData.result.total_findings || 0 }
|
||||
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>
|
||||
</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) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm py-1.5 border-b border-gray-50 last:border-0">
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { FollowUpQuestion } from '../_hooks/useAgentAnalysis'
|
||||
|
||||
const SEVERITY_STYLE: Record<string, { border: string; bg: string; icon: string }> = {
|
||||
high: { border: 'border-red-300', bg: 'bg-red-50', icon: '!!' },
|
||||
medium: { border: 'border-yellow-300', bg: 'bg-yellow-50', icon: '!' },
|
||||
low: { border: 'border-blue-300', bg: 'bg-blue-50', icon: 'i' },
|
||||
}
|
||||
|
||||
interface Props {
|
||||
questions: FollowUpQuestion[]
|
||||
answers: Record<string, boolean>
|
||||
onAnswer: (questionId: string, answer: boolean) => void
|
||||
}
|
||||
|
||||
export function FollowUpQuestions({ questions, answers, onAnswer }: Props) {
|
||||
const unanswered = questions.filter(q => answers[q.id] === undefined)
|
||||
const answered = questions.filter(q => answers[q.id] !== undefined)
|
||||
|
||||
if (questions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Rueckfragen zur manuellen Pruefung ({unanswered.length} offen)
|
||||
</h4>
|
||||
|
||||
{/* Unanswered questions */}
|
||||
{unanswered.map(q => {
|
||||
const style = SEVERITY_STYLE[q.severity] || SEVERITY_STYLE.medium
|
||||
return (
|
||||
<div key={q.id} className={`border ${style.border} ${style.bg} rounded-lg p-4`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`mt-0.5 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
q.severity === 'high' ? 'bg-red-200 text-red-800' :
|
||||
q.severity === 'medium' ? 'bg-yellow-200 text-yellow-800' :
|
||||
'bg-blue-200 text-blue-800'
|
||||
}`}>
|
||||
{SEVERITY_STYLE[q.severity]?.icon || '?'}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{q.question}</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Rechtsgrundlage: {q.legal_basis}</p>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={() => onAnswer(q.id, true)}
|
||||
className="px-4 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors"
|
||||
>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAnswer(q.id, false)}
|
||||
className="px-4 py-1.5 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Nein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Answered questions */}
|
||||
{answered.map(q => {
|
||||
const isYes = answers[q.id]
|
||||
return (
|
||||
<div key={q.id} className={`border rounded-lg p-3 ${isYes ? 'border-green-200 bg-green-50' : 'border-red-200 bg-red-50'}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm ${isYes ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{isYes ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-700">{q.question}</span>
|
||||
<span className={`ml-auto text-xs font-medium ${isYes ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{isYes ? 'Ja — OK' : 'Nein — Finding erstellt'}
|
||||
</span>
|
||||
</div>
|
||||
{!isYes && (
|
||||
<p className="text-xs text-red-600 mt-1 ml-6">{q.finding_if_no}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
interface ServiceInfo {
|
||||
name: string
|
||||
category: string
|
||||
provider: string
|
||||
country: string
|
||||
eu_adequate: boolean
|
||||
requires_consent: boolean
|
||||
legal_ref: string
|
||||
in_dse: boolean
|
||||
status: string
|
||||
}
|
||||
|
||||
interface ScanFinding {
|
||||
code: string
|
||||
severity: string
|
||||
text: string
|
||||
correction: string
|
||||
doc_title: string
|
||||
}
|
||||
|
||||
interface DiscoveredDocument {
|
||||
title: string
|
||||
url: string
|
||||
doc_type: string
|
||||
language: string
|
||||
word_count: number
|
||||
completeness_pct: number
|
||||
findings_count: number
|
||||
}
|
||||
|
||||
interface ScanData {
|
||||
pages_scanned: number
|
||||
pages_list: string[]
|
||||
services: ServiceInfo[]
|
||||
findings: ScanFinding[]
|
||||
discovered_documents?: DiscoveredDocument[]
|
||||
ai_detected: boolean
|
||||
chatbot_detected: boolean
|
||||
chatbot_provider: string
|
||||
missing_pages: Record<string, number>
|
||||
email_status: string
|
||||
}
|
||||
|
||||
const STATUS_ICON: Record<string, { icon: string; color: string }> = {
|
||||
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; 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 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">
|
||||
{/* Summary Bar */}
|
||||
<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</p>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-green-700">{okCount}</p>
|
||||
<p className="text-xs text-gray-500">Dokumentiert</p>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-3 text-center">
|
||||
<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-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>
|
||||
|
||||
{/* Scanned Pages */}
|
||||
{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
|
||||
</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 ? '\u2717' : '\u2713'} {p}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Services 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>
|
||||
)}
|
||||
|
||||
{/* === Document-Centric View === */}
|
||||
{docs.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Rechtliche Dokumente ({docs.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{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 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">
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded ${sev.text} bg-white`}>
|
||||
{f.severity}
|
||||
</span>
|
||||
<p className="text-sm text-gray-800 flex-1">{f.text}</p>
|
||||
</div>
|
||||
{f.correction && (
|
||||
<div className="mt-2">
|
||||
<button onClick={() => setExpandedCorrection(isExp ? null : corrKey)}
|
||||
className="text-xs text-purple-600 hover:text-purple-800 font-medium">
|
||||
{isExp ? 'Korrektur ausblenden' : 'Korrekturvorschlag'}
|
||||
</button>
|
||||
{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">
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Status */}
|
||||
{data.email_status && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${data.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {data.email_status === 'sent' ? 'Gesendet' : data.email_status}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export interface FollowUpQuestion {
|
||||
id: string
|
||||
question: string
|
||||
legal_basis: string
|
||||
severity: 'high' | 'medium' | 'low'
|
||||
finding_if_no: string
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
url: string
|
||||
classification: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
escalation_level: string
|
||||
responsible_role: string
|
||||
findings: string[]
|
||||
required_controls: string[]
|
||||
summary: string
|
||||
email_status: string
|
||||
analyzed_at: string
|
||||
follow_up_questions: FollowUpQuestion[]
|
||||
follow_up_answers: Record<string, boolean>
|
||||
}
|
||||
|
||||
const ESCALATION_ROLES: Record<string, string> = {
|
||||
E0: 'Kein Handlungsbedarf',
|
||||
E1: 'Teamleitung Datenschutz',
|
||||
E2: 'Datenschutzbeauftragter (DSB)',
|
||||
E3: 'DSB + Rechtsabteilung',
|
||||
}
|
||||
|
||||
export function useAgentAnalysis() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<AnalysisResult | null>(null)
|
||||
const [history, setHistory] = useState<AnalysisResult[]>([])
|
||||
|
||||
async function analyze(url: string, mode: string = 'post_launch') {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const fetchRes = await fetch('/api/sdk/v1/agent/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, mode }),
|
||||
})
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
throw new Error(`Analyse fehlgeschlagen: ${fetchRes.status}`)
|
||||
}
|
||||
|
||||
const data = await fetchRes.json()
|
||||
const analysisResult: AnalysisResult = {
|
||||
url,
|
||||
classification: data.classification || 'unknown',
|
||||
risk_level: data.risk_level || 'unknown',
|
||||
risk_score: data.risk_score || 0,
|
||||
escalation_level: data.escalation_level || 'E0',
|
||||
responsible_role: ESCALATION_ROLES[data.escalation_level] || ESCALATION_ROLES.E0,
|
||||
findings: data.findings || [],
|
||||
required_controls: data.required_controls || [],
|
||||
summary: data.summary || '',
|
||||
email_status: data.email_status || 'pending',
|
||||
analyzed_at: new Date().toISOString(),
|
||||
follow_up_questions: data.follow_up_questions || [],
|
||||
follow_up_answers: {},
|
||||
}
|
||||
|
||||
setResult(analysisResult)
|
||||
setHistory(prev => [analysisResult, ...prev].slice(0, 20))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function answerFollowUp(questionId: string, answer: boolean) {
|
||||
if (!result) return
|
||||
const question = result.follow_up_questions.find(q => q.id === questionId)
|
||||
const newAnswers = { ...result.follow_up_answers, [questionId]: answer }
|
||||
const newFindings = [...result.findings]
|
||||
|
||||
// If user answered "no" → add the finding
|
||||
if (!answer && question) {
|
||||
newFindings.push(question.finding_if_no)
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...result,
|
||||
findings: newFindings,
|
||||
follow_up_answers: newAnswers,
|
||||
}
|
||||
setResult(updated)
|
||||
// Update history too
|
||||
setHistory(prev => prev.map(h => h.analyzed_at === result.analyzed_at ? updated : h))
|
||||
}
|
||||
|
||||
return { analyze, answerFollowUp, loading, error, result, history }
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
'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 { DocCheckTab } from './_components/DocCheckTab'
|
||||
|
||||
type AnalysisMode = 'pre_launch' | 'post_launch'
|
||||
type AnalysisTab = 'quick' | 'scan' | 'doc-check'
|
||||
|
||||
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: '🌐' },
|
||||
]
|
||||
|
||||
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: 'doc-check', label: 'Dokumenten-Pruefung', desc: 'Einzelne Dokumente gezielt pruefen' },
|
||||
]
|
||||
|
||||
export default function AgentPage() {
|
||||
// Restore state from localStorage on mount
|
||||
const [url, setUrl] = useState(() => typeof window !== 'undefined' ? localStorage.getItem('agent-scan-url') || '' : '')
|
||||
const [mode, setMode] = useState<AnalysisMode>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-mode') as AnalysisMode : null) || 'post_launch')
|
||||
const [tab, setTab] = useState<AnalysisTab>(() => (typeof window !== 'undefined' ? localStorage.getItem('agent-scan-tab') as AnalysisTab : null) || 'quick')
|
||||
const [scanLoading, setScanLoading] = useState(false)
|
||||
const [scanError, setScanError] = useState<string | null>(null)
|
||||
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 }[]>(() => {
|
||||
if (typeof window === 'undefined') return []
|
||||
try { return JSON.parse(localStorage.getItem('agent-scan-history') || '[]') } catch { return [] }
|
||||
})
|
||||
const { analyze, answerFollowUp, loading, error, result, history } = useAgentAnalysis()
|
||||
|
||||
// Persist state to localStorage
|
||||
React.useEffect(() => { localStorage.setItem('agent-scan-url', url) }, [url])
|
||||
React.useEffect(() => { localStorage.setItem('agent-scan-mode', mode) }, [mode])
|
||||
React.useEffect(() => { localStorage.setItem('agent-scan-tab', tab) }, [tab])
|
||||
React.useEffect(() => { if (scanData?.services) localStorage.setItem('agent-scan-result', JSON.stringify(scanData)) }, [scanData])
|
||||
|
||||
// Resume polling if scan was in progress when page was left
|
||||
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') {
|
||||
setScanError(data.error || 'Scan fehlgeschlagen')
|
||||
setScanProgress('')
|
||||
setScanLoading(false)
|
||||
localStorage.removeItem('agent-scan-id')
|
||||
setActiveScanId('')
|
||||
return
|
||||
}
|
||||
if (data.status === 'not_found') {
|
||||
setScanProgress('')
|
||||
setScanLoading(false)
|
||||
localStorage.removeItem('agent-scan-id')
|
||||
setActiveScanId('')
|
||||
return
|
||||
}
|
||||
} catch { /* retry */ }
|
||||
}
|
||||
}
|
||||
poll()
|
||||
return () => { cancelled = true }
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const _addToHistory = (result: any) => {
|
||||
const entry = {
|
||||
url: url || result.url || '',
|
||||
date: new Date().toISOString(),
|
||||
findings: result.findings?.length || 0,
|
||||
docs: result.discovered_documents?.length || 0,
|
||||
}
|
||||
const updated = [entry, ...scanHistory].slice(0, 50)
|
||||
setScanHistory(updated)
|
||||
localStorage.setItem('agent-scan-history', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
const _loadFromHistory = (entry: { url: string }) => {
|
||||
setUrl(entry.url)
|
||||
setTab('scan')
|
||||
// Load saved result if same URL
|
||||
try {
|
||||
const saved = localStorage.getItem('agent-scan-result')
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (parsed.url === entry.url) {
|
||||
setScanData(parsed)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!url.trim()) return
|
||||
|
||||
if (tab === 'quick') {
|
||||
analyze(url.trim(), mode)
|
||||
} else {
|
||||
setScanLoading(true)
|
||||
setScanError(null)
|
||||
setScanData(null)
|
||||
setScanProgress('Scan wird gestartet...')
|
||||
try {
|
||||
// Step 1: Start async scan
|
||||
const startRes = await fetch('/api/sdk/v1/agent/scan', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url.trim(), mode }),
|
||||
})
|
||||
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)
|
||||
|
||||
// Step 2: Poll for results
|
||||
let attempts = 0
|
||||
const maxAttempts = 120 // 10 min at 5s intervals
|
||||
while (attempts < maxAttempts) {
|
||||
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 >= maxAttempts) throw new Error('Scan-Timeout (10 Minuten)')
|
||||
} catch (e) {
|
||||
setScanError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
setScanProgress('')
|
||||
} finally {
|
||||
setScanLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = tab === 'quick' ? loading : scanLoading
|
||||
const currentError = tab === 'quick' ? error : scanError
|
||||
|
||||
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>
|
||||
</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">
|
||||
{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'}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Doc Check Tab — own component */}
|
||||
{tab === 'doc-check' && <DocCheckTab />}
|
||||
|
||||
{/* URL Input (quick + scan only) */}
|
||||
{tab !== 'doc-check' && <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>}
|
||||
|
||||
{/* Scan Progress */}
|
||||
{scanProgress && tab === 'scan' && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scan Result — only render when we have a complete response with services */}
|
||||
{tab === 'scan' && scanData && scanData.services && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
|
||||
<ScanResult data={scanData} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History (quick only) */}
|
||||
{tab === 'quick' && (
|
||||
<AnalysisHistory history={history} onSelect={r => { setUrl(r.url); analyze(r.url, mode) }} />
|
||||
)}
|
||||
|
||||
{/* Scan History */}
|
||||
{tab === 'scan' && 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={() => _loadFromHistory(h)}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -8,9 +8,178 @@ import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||
import { RiskPyramid } from './_components/RiskPyramid'
|
||||
import { AddSystemForm } from './_components/AddSystemForm'
|
||||
import { AISystemCard } from './_components/AISystemCard'
|
||||
import DecisionTreeWizard from '@/components/sdk/ai-act/DecisionTreeWizard'
|
||||
|
||||
type TabId = 'overview' | 'decision-tree' | 'results'
|
||||
|
||||
// SAVED RESULTS TAB
|
||||
// =============================================================================
|
||||
|
||||
interface SavedResult {
|
||||
id: string
|
||||
system_name: string
|
||||
system_description?: string
|
||||
high_risk_result: string
|
||||
gpai_result: { gpai_category: string; is_systemic_risk: boolean }
|
||||
combined_obligations: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function SavedResultsTab() {
|
||||
const [results, setResults] = useState<SavedResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree/results')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResults(data.results || [])
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Ergebnis wirklich löschen?')) return
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/ucca/decision-tree/results/${id}`, { method: 'DELETE' })
|
||||
if (res.ok) {
|
||||
setResults(prev => prev.filter(r => r.id !== id))
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const riskLabels: Record<string, string> = {
|
||||
unacceptable: 'Unzulässig',
|
||||
high_risk: 'Hochrisiko',
|
||||
limited_risk: 'Begrenztes Risiko',
|
||||
minimal_risk: 'Minimales Risiko',
|
||||
not_applicable: 'Nicht anwendbar',
|
||||
}
|
||||
|
||||
const riskColors: Record<string, string> = {
|
||||
unacceptable: 'bg-red-100 text-red-700',
|
||||
high_risk: 'bg-orange-100 text-orange-700',
|
||||
limited_risk: 'bg-yellow-100 text-yellow-700',
|
||||
minimal_risk: 'bg-green-100 text-green-700',
|
||||
not_applicable: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const gpaiLabels: Record<string, string> = {
|
||||
none: 'Kein GPAI',
|
||||
standard: 'GPAI Standard',
|
||||
systemic: 'GPAI Systemisch',
|
||||
}
|
||||
|
||||
const gpaiColors: Record<string, string> = {
|
||||
none: 'bg-gray-100 text-gray-500',
|
||||
standard: 'bg-blue-100 text-blue-700',
|
||||
systemic: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton />
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine Ergebnisse vorhanden</h3>
|
||||
<p className="mt-2 text-gray-500">Nutzen Sie den Entscheidungsbaum, um KI-Systeme zu klassifizieren.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{results.map(r => (
|
||||
<div key={r.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900">{r.system_name}</h4>
|
||||
{r.system_description && (
|
||||
<p className="text-sm text-gray-500 mt-0.5">{r.system_description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[r.high_risk_result] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{riskLabels[r.high_risk_result] || r.high_risk_result}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${gpaiColors[r.gpai_result?.gpai_category] || 'bg-gray-100 text-gray-500'}`}>
|
||||
{gpaiLabels[r.gpai_result?.gpai_category] || 'Kein GPAI'}
|
||||
</span>
|
||||
{r.gpai_result?.is_systemic_risk && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Systemisch</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">
|
||||
{r.combined_obligations?.length || 0} Pflichten · {new Date(r.created_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(r.id)}
|
||||
className="px-3 py-1 text-xs text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// TABS
|
||||
// =============================================================================
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
label: 'Übersicht',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decision-tree',
|
||||
label: 'Entscheidungsbaum',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'results',
|
||||
label: 'Ergebnisse',
|
||||
icon: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// MAIN PAGE
|
||||
|
||||
export default function AIActPage() {
|
||||
const { state } = useSDK()
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||||
const [systems, setSystems] = useState<AISystem[]>([])
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -178,17 +347,38 @@ export default function AIActPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
{activeTab === 'overview' && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
KI-System registrieren
|
||||
</button>
|
||||
)}
|
||||
</StepHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg w-fit">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-md transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
@@ -196,82 +386,105 @@ export default function AIActPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
{/* Tab: Overview */}
|
||||
{activeTab === 'overview' && (
|
||||
<>
|
||||
{/* Add/Edit System Form */}
|
||||
{showAddForm && (
|
||||
<AddSystemForm
|
||||
onSubmit={handleAddSystem}
|
||||
onCancel={() => { setShowAddForm(false); setEditingSystem(null) }}
|
||||
initialData={editingSystem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<div className="text-sm text-gray-500">KI-Systeme gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{systems.length}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-orange-200 p-6">
|
||||
<div className="text-sm text-orange-600">Hochrisiko</div>
|
||||
<div className="text-3xl font-bold text-orange-600">{highRiskCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Konform</div>
|
||||
<div className="text-3xl font-bold text-green-600">{compliantCount}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Nicht klassifiziert</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{unclassifiedCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Risk Pyramid */}
|
||||
<RiskPyramid systems={systems} />
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'high-risk', 'limited-risk', 'minimal-risk', 'unclassified', 'compliant', 'non-compliant'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filter === f
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'high-risk' ? 'Hochrisiko' :
|
||||
f === 'limited-risk' ? 'Begrenztes Risiko' :
|
||||
f === 'minimal-risk' ? 'Minimales Risiko' :
|
||||
f === 'unclassified' ? 'Nicht klassifiziert' :
|
||||
f === 'compliant' ? 'Konform' : 'Nicht konform'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{loading && <LoadingSkeleton />}
|
||||
|
||||
{/* AI Systems List */}
|
||||
{!loading && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredSystems.map(system => (
|
||||
<AISystemCard
|
||||
key={system.id}
|
||||
system={system}
|
||||
onAssess={() => handleAssess(system.id)}
|
||||
onEdit={() => handleEdit(system)}
|
||||
onDelete={() => handleDelete(system.id)}
|
||||
assessing={assessingId === system.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && filteredSystems.length === 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine KI-Systeme gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Passen Sie den Filter an oder registrieren Sie ein neues KI-System.</p>
|
||||
</div>
|
||||
{/* Tab: Decision Tree */}
|
||||
{activeTab === 'decision-tree' && (
|
||||
<DecisionTreeWizard />
|
||||
)}
|
||||
|
||||
{/* Tab: Results */}
|
||||
{activeTab === 'results' && (
|
||||
<SavedResultsTab />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface Registration {
|
||||
id: string
|
||||
system_name: string
|
||||
system_version: string
|
||||
risk_classification: string
|
||||
gpai_classification: string
|
||||
registration_status: string
|
||||
eu_database_id: string
|
||||
provider_name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
draft: { bg: 'bg-gray-100', text: 'text-gray-700', label: 'Entwurf' },
|
||||
ready: { bg: 'bg-blue-100', text: 'text-blue-700', label: 'Bereit' },
|
||||
submitted: { bg: 'bg-yellow-100', text: 'text-yellow-700', label: 'Eingereicht' },
|
||||
registered: { bg: 'bg-green-100', text: 'text-green-700', label: 'Registriert' },
|
||||
update_required: { bg: 'bg-orange-100', text: 'text-orange-700', label: 'Update noetig' },
|
||||
withdrawn: { bg: 'bg-red-100', text: 'text-red-700', label: 'Zurueckgezogen' },
|
||||
}
|
||||
|
||||
const RISK_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high_risk: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
limited_risk: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
minimal_risk: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
not_classified: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
system_name: '',
|
||||
system_version: '1.0',
|
||||
system_description: '',
|
||||
intended_purpose: '',
|
||||
provider_name: '',
|
||||
provider_legal_form: '',
|
||||
provider_address: '',
|
||||
provider_country: 'DE',
|
||||
eu_representative_name: '',
|
||||
eu_representative_contact: '',
|
||||
risk_classification: 'not_classified',
|
||||
annex_iii_category: '',
|
||||
gpai_classification: 'none',
|
||||
conformity_assessment_type: 'internal',
|
||||
notified_body_name: '',
|
||||
notified_body_id: '',
|
||||
ce_marking: false,
|
||||
training_data_summary: '',
|
||||
}
|
||||
|
||||
export default function AIRegistrationPage() {
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showWizard, setShowWizard] = useState(false)
|
||||
const [wizardStep, setWizardStep] = useState(1)
|
||||
const [form, setForm] = useState({ ...INITIAL_FORM })
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => { loadRegistrations() }, [])
|
||||
|
||||
async function loadRegistrations() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration')
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setRegistrations(data.registrations || [])
|
||||
}
|
||||
} catch {
|
||||
setError('Fehler beim Laden')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const resp = await fetch('/api/sdk/v1/ai-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowWizard(false)
|
||||
setForm({ ...INITIAL_FORM })
|
||||
setWizardStep(1)
|
||||
loadRegistrations()
|
||||
} else {
|
||||
const data = await resp.json()
|
||||
setError(data.error || 'Fehler beim Erstellen')
|
||||
}
|
||||
} catch {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sdk/v1/ai-registration/${id}`)
|
||||
if (resp.ok) {
|
||||
const reg = await resp.json()
|
||||
// Build export JSON client-side
|
||||
const exportData = {
|
||||
schema_version: '1.0',
|
||||
submission_type: 'ai_system_registration',
|
||||
regulation: 'EU AI Act (EU) 2024/1689',
|
||||
article: 'Art. 49',
|
||||
provider: { name: reg.provider_name, address: reg.provider_address, country: reg.provider_country },
|
||||
system: { name: reg.system_name, version: reg.system_version, description: reg.system_description, purpose: reg.intended_purpose },
|
||||
classification: { risk_level: reg.risk_classification, annex_iii: reg.annex_iii_category, gpai: reg.gpai_classification },
|
||||
conformity: { type: reg.conformity_assessment_type, ce_marking: reg.ce_marking },
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `eu_ai_registration_${reg.system_name.replace(/\s+/g, '_')}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(id: string, status: string) {
|
||||
try {
|
||||
await fetch(`/api/sdk/v1/ai-registration/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
})
|
||||
loadRegistrations()
|
||||
} catch {
|
||||
setError('Status-Aenderung fehlgeschlagen')
|
||||
}
|
||||
}
|
||||
|
||||
const updateForm = (updates: Partial<typeof form>) => setForm(prev => ({ ...prev, ...updates }))
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: 'Anbieter', desc: 'Unternehmensangaben' },
|
||||
{ id: 2, title: 'System', desc: 'KI-System Details' },
|
||||
{ id: 3, title: 'Klassifikation', desc: 'Risikoeinstufung' },
|
||||
{ id: 4, title: 'Konformitaet', desc: 'CE & Notified Body' },
|
||||
{ id: 5, title: 'Trainingsdaten', desc: 'Datenzusammenfassung' },
|
||||
{ id: 6, title: 'Pruefung', desc: 'Zusammenfassung & Export' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">EU AI Database Registrierung</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Art. 49 KI-Verordnung (EU) 2024/1689 — Registrierung von Hochrisiko-KI-Systemen</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
+ Neue Registrierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
{['draft', 'ready', 'submitted', 'registered'].map(status => {
|
||||
const count = registrations.filter(r => r.registration_status === status).length
|
||||
const style = STATUS_STYLES[status]
|
||||
return (
|
||||
<div key={status} className={`p-4 rounded-xl border ${style.bg}`}>
|
||||
<div className={`text-2xl font-bold ${style.text}`}>{count}</div>
|
||||
<div className="text-sm text-gray-600">{style.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Registrations List */}
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : registrations.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Registrierungen</p>
|
||||
<p className="text-sm">Erstelle eine neue Registrierung fuer dein Hochrisiko-KI-System.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{registrations.map(reg => {
|
||||
const status = STATUS_STYLES[reg.registration_status] || STATUS_STYLES.draft
|
||||
const risk = RISK_STYLES[reg.risk_classification] || RISK_STYLES.not_classified
|
||||
return (
|
||||
<div key={reg.id} className="bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{reg.system_name}</h3>
|
||||
<span className="text-sm text-gray-400">v{reg.system_version}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${status.bg} ${status.text}`}>{status.label}</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${risk.bg} ${risk.text}`}>{reg.risk_classification.replace('_', ' ')}</span>
|
||||
{reg.gpai_classification !== 'none' && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">GPAI: {reg.gpai_classification}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{reg.provider_name && <span>{reg.provider_name} · </span>}
|
||||
{reg.eu_database_id && <span>EU-ID: {reg.eu_database_id} · </span>}
|
||||
<span>{new Date(reg.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleExport(reg.id)} className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||
JSON Export
|
||||
</button>
|
||||
{reg.registration_status === 'draft' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'ready')} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">
|
||||
Bereit markieren
|
||||
</button>
|
||||
)}
|
||||
{reg.registration_status === 'ready' && (
|
||||
<button onClick={() => handleStatusChange(reg.id, 'submitted')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
Als eingereicht markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wizard Modal */}
|
||||
{showWizard && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">Neue EU AI Registrierung</h2>
|
||||
<button onClick={() => { setShowWizard(false); setWizardStep(1) }} className="text-gray-400 hover:text-gray-600 text-2xl">×</button>
|
||||
</div>
|
||||
{/* Step Indicator */}
|
||||
<div className="flex gap-1">
|
||||
{STEPS.map(step => (
|
||||
<button key={step.id} onClick={() => setWizardStep(step.id)}
|
||||
className={`flex-1 py-2 text-xs rounded-lg transition-all ${
|
||||
wizardStep === step.id ? 'bg-purple-100 text-purple-700 font-medium' :
|
||||
wizardStep > step.id ? 'bg-green-50 text-green-700' : 'bg-gray-50 text-gray-400'
|
||||
}`}>
|
||||
{wizardStep > step.id ? '✓ ' : ''}{step.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Step 1: Provider */}
|
||||
{wizardStep === 1 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Anbieter-Informationen</h3>
|
||||
<p className="text-sm text-gray-500">Angaben zum Anbieter des KI-Systems gemaess Art. 49 KI-VO.</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname *</label>
|
||||
<input value={form.provider_name} onChange={e => updateForm({ provider_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Acme GmbH" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rechtsform</label>
|
||||
<input value={form.provider_legal_form} onChange={e => updateForm({ provider_legal_form: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="GmbH" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Adresse</label>
|
||||
<input value={form.provider_address} onChange={e => updateForm({ provider_address: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Musterstr. 1, 20095 Hamburg" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
|
||||
<select value={form.provider_country} onChange={e => updateForm({ provider_country: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Oesterreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
<option value="OTHER">Anderes Land</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">EU-Repraesentant (falls Non-EU)</label>
|
||||
<input value={form.eu_representative_name} onChange={e => updateForm({ eu_representative_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 2: System */}
|
||||
{wizardStep === 2 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">KI-System Details</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systemname *</label>
|
||||
<input value={form.system_name} onChange={e => updateForm({ system_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="z.B. HR Copilot" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Version</label>
|
||||
<input value={form.system_version} onChange={e => updateForm({ system_version: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="1.0" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systembeschreibung</label>
|
||||
<textarea value={form.system_description} onChange={e => updateForm({ system_description: e.target.value })} rows={3}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Beschreibe was das KI-System tut..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Einsatzzweck (Intended Purpose)</label>
|
||||
<textarea value={form.intended_purpose} onChange={e => updateForm({ intended_purpose: e.target.value })} rows={2}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" placeholder="Wofuer wird das System eingesetzt?" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 3: Classification */}
|
||||
{wizardStep === 3 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Risiko-Klassifikation</h3>
|
||||
<p className="text-sm text-gray-500">Basierend auf dem AI Act Decision Tree oder manueller Einstufung.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Risikoklasse</label>
|
||||
<select value={form.risk_classification} onChange={e => updateForm({ risk_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_classified">Noch nicht klassifiziert</option>
|
||||
<option value="minimal_risk">Minimal Risk</option>
|
||||
<option value="limited_risk">Limited Risk</option>
|
||||
<option value="high_risk">High Risk</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.risk_classification === 'high_risk' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Annex III Kategorie</label>
|
||||
<select value={form.annex_iii_category} onChange={e => updateForm({ annex_iii_category: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="">Bitte waehlen...</option>
|
||||
<option value="biometric">1. Biometrische Identifizierung</option>
|
||||
<option value="critical_infrastructure">2. Kritische Infrastruktur</option>
|
||||
<option value="education">3. Bildung und Berufsausbildung</option>
|
||||
<option value="employment">4. Beschaeftigung und Arbeitnehmerverwaltung</option>
|
||||
<option value="essential_services">5. Zugang zu wesentlichen Diensten</option>
|
||||
<option value="law_enforcement">6. Strafverfolgung</option>
|
||||
<option value="migration">7. Migration und Grenzkontrolle</option>
|
||||
<option value="justice">8. Rechtspflege und Demokratie</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">GPAI Klassifikation</label>
|
||||
<select value={form.gpai_classification} onChange={e => updateForm({ gpai_classification: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="none">Kein GPAI</option>
|
||||
<option value="standard">GPAI (Standard)</option>
|
||||
<option value="systemic">GPAI mit systemischem Risiko</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
<strong>Tipp:</strong> Nutze den <a href="/sdk/ai-act" className="underline">AI Act Decision Tree</a> fuer eine strukturierte Klassifikation.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 4: Conformity */}
|
||||
{wizardStep === 4 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Konformitaetsbewertung</h3>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Art der Konformitaetsbewertung</label>
|
||||
<select value={form.conformity_assessment_type} onChange={e => updateForm({ conformity_assessment_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent">
|
||||
<option value="not_required">Nicht erforderlich</option>
|
||||
<option value="internal">Interne Konformitaetsbewertung</option>
|
||||
<option value="third_party">Drittpartei-Bewertung (Notified Body)</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.conformity_assessment_type === 'third_party' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body Name</label>
|
||||
<input value={form.notified_body_name} onChange={e => updateForm({ notified_body_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notified Body ID</label>
|
||||
<input value={form.notified_body_id} onChange={e => updateForm({ notified_body_id: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-3 p-3 rounded-lg border hover:bg-gray-50 cursor-pointer">
|
||||
<input type="checkbox" checked={form.ce_marking} onChange={e => updateForm({ ce_marking: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-gray-300 text-purple-600" />
|
||||
<span className="text-sm font-medium text-gray-900">CE-Kennzeichnung angebracht</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 5: Training Data */}
|
||||
{wizardStep === 5 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Trainingsdaten-Zusammenfassung</h3>
|
||||
<p className="text-sm text-gray-500">Art. 10 KI-VO — Keine vollstaendige Offenlegung, sondern Kategorien und Herkunft.</p>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zusammenfassung der Trainingsdaten</label>
|
||||
<textarea value={form.training_data_summary} onChange={e => updateForm({ training_data_summary: e.target.value })} rows={5}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Beschreibe die verwendeten Datenquellen: - Oeffentliche Daten (z.B. Wikipedia, Common Crawl) - Lizenzierte Daten (z.B. Fachpublikationen) - Synthetische Daten - Unternehmensinterne Daten" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Step 6: Review */}
|
||||
{wizardStep === 6 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900">Zusammenfassung</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div><span className="text-gray-500">Anbieter:</span> <strong>{form.provider_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Land:</span> <strong>{form.provider_country}</strong></div>
|
||||
<div><span className="text-gray-500">System:</span> <strong>{form.system_name || '–'}</strong></div>
|
||||
<div><span className="text-gray-500">Version:</span> <strong>{form.system_version}</strong></div>
|
||||
<div><span className="text-gray-500">Risiko:</span> <strong>{form.risk_classification}</strong></div>
|
||||
<div><span className="text-gray-500">GPAI:</span> <strong>{form.gpai_classification}</strong></div>
|
||||
<div><span className="text-gray-500">Konformitaet:</span> <strong>{form.conformity_assessment_type}</strong></div>
|
||||
<div><span className="text-gray-500">CE:</span> <strong>{form.ce_marking ? 'Ja' : 'Nein'}</strong></div>
|
||||
</div>
|
||||
{form.intended_purpose && (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<span className="text-gray-500">Zweck:</span> {form.intended_purpose}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||
<strong>Hinweis:</strong> Die EU AI Datenbank befindet sich noch im Aufbau. Die Registrierung wird lokal gespeichert und kann spaeter uebermittelt werden.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="p-6 border-t flex justify-between">
|
||||
<button onClick={() => wizardStep > 1 ? setWizardStep(wizardStep - 1) : setShowWizard(false)}
|
||||
className="px-4 py-2 text-gray-700 border rounded-lg hover:bg-gray-50">
|
||||
{wizardStep === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
{wizardStep < 6 ? (
|
||||
<button onClick={() => setWizardStep(wizardStep + 1)}
|
||||
disabled={wizardStep === 2 && !form.system_name}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
Weiter
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleSubmit} disabled={submitting || !form.system_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{submitting ? 'Speichere...' : 'Registrierung erstellen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -228,24 +228,39 @@ export const ARCH_SERVICES: ArchService[] = [
|
||||
dependsOn: ['qdrant', 'ollama', 'postgresql'],
|
||||
},
|
||||
{
|
||||
id: 'document-crawler',
|
||||
name: 'Document Crawler',
|
||||
nameShort: 'Crawler',
|
||||
id: 'control-pipeline',
|
||||
name: 'Control Pipeline',
|
||||
nameShort: 'Pipeline',
|
||||
layer: 'backend',
|
||||
tech: 'Python / FastAPI',
|
||||
port: 8098,
|
||||
url: 'https://macmini:8098',
|
||||
container: 'bp-compliance-document-crawler',
|
||||
description: 'Dokument-Analyse (PDF, DOCX, XLSX, PPTX), Gap-Analyse, IPFS-Archivierung.',
|
||||
descriptionLong: 'Der Document Crawler nimmt hochgeladene Dokumente (PDF, DOCX, XLSX, PPTX) entgegen, extrahiert deren Inhalt und fuehrt eine Gap-Analyse gegen bestehende Compliance-Anforderungen durch. Dafuer leitet er die Textinhalte an den AI Compliance SDK weiter, der die semantische Analyse uebernimmt. Abgeschlossene Dokumente koennen ueber den DSMS-Service dezentral auf IPFS archiviert werden.',
|
||||
dbTables: [],
|
||||
ragCollections: [],
|
||||
apiEndpoints: [
|
||||
'POST /analyze',
|
||||
'POST /gap-analysis',
|
||||
'POST /archive',
|
||||
container: 'bp-core-control-pipeline',
|
||||
description: 'RAG-zu-Controls Pipeline: Control Generation, Pass 0a/0b, Ontology, Dedup, Dependency Engine, Applicability.',
|
||||
descriptionLong: 'Die Control Pipeline ist das Herzsttueck der automatisierten Compliance-Control-Generierung. Sie verarbeitet ~105.000 RAG-Chunks aus EU/DE-Regulierungen in 6 Phasen: (1) RAG Ingestion, (2) 7-Stufen Control Generation (Lizenz-Gate + Claude LLM), (3) Pass 0a Obligation Extraction (~181k Obligations), (4) Pass 0b Atomic Composition (MCP-taugliche Controls mit assertion/pass_criteria/fail_criteria), (5) Embedding-basierte Deduplizierung mit LLM-Verifikation, (6) Dependency Engine (5 Typen: supersedes, prerequisite, compensating_control, scope_exclusion, conditional_requirement) mit automatischer Generierung via Ontology, Pattern-Regeln und Domain Packs (DSGVO, AI Act, CRA, Security, Arbeitsrecht). 126+ Tests, alle bestanden.',
|
||||
dbTables: [
|
||||
'canonical_controls', 'obligation_candidates', 'control_parent_links',
|
||||
'control_dependencies', 'control_evaluation_results',
|
||||
'canonical_processed_chunks', 'canonical_generation_jobs',
|
||||
'control_dedup_reviews', 'control_patterns',
|
||||
],
|
||||
dependsOn: ['ai-compliance-sdk', 'dsms'],
|
||||
ragCollections: [
|
||||
'bp_compliance_gesetze', 'bp_compliance_datenschutz',
|
||||
'bp_compliance_ce', 'bp_dsfa_corpus', 'bp_legal_templates',
|
||||
],
|
||||
apiEndpoints: [
|
||||
'POST /v1/canonical/generate',
|
||||
'GET /v1/canonical/controls',
|
||||
'POST /v1/canonical/controls/applicable',
|
||||
'POST /v1/canonical/generate/submit-pass0b',
|
||||
'POST /v1/canonical/generate/process-batch',
|
||||
'GET /v1/canonical/generate/quality-metrics',
|
||||
'POST /v1/dependencies/generate',
|
||||
'POST /v1/dependencies/evaluate',
|
||||
'GET /v1/dependencies/graph',
|
||||
'POST /v1/document-compliance/required',
|
||||
],
|
||||
dependsOn: ['postgresql', 'qdrant', 'ollama'],
|
||||
},
|
||||
{
|
||||
id: 'compliance-tts',
|
||||
@@ -383,7 +398,7 @@ export const ARCH_EDGES: ArchEdge[] = [
|
||||
// Frontend → Backend
|
||||
{ source: 'admin-compliance', target: 'backend-compliance', label: 'REST API' },
|
||||
{ source: 'admin-compliance', target: 'ai-compliance-sdk', label: 'REST API' },
|
||||
{ source: 'admin-compliance', target: 'document-crawler', label: 'REST API' },
|
||||
{ source: 'admin-compliance', target: 'control-pipeline', label: 'REST API' },
|
||||
|
||||
// Backend → Infrastructure
|
||||
{ source: 'backend-compliance', target: 'postgresql', label: 'SQLAlchemy' },
|
||||
@@ -392,12 +407,9 @@ export const ARCH_EDGES: ArchEdge[] = [
|
||||
{ source: 'ai-compliance-sdk', target: 'ollama', label: 'LLM Inference' },
|
||||
{ source: 'ai-compliance-sdk', target: 'postgresql', label: 'GORM' },
|
||||
{ source: 'compliance-tts', target: 'minio', label: 'Audio/Video' },
|
||||
|
||||
// Backend → Backend
|
||||
{ source: 'document-crawler', target: 'ai-compliance-sdk', label: 'LLM Gateway' },
|
||||
|
||||
// Backend → Data Sovereignty
|
||||
{ source: 'document-crawler', target: 'dsms', label: 'IPFS Archive' },
|
||||
{ source: 'control-pipeline', target: 'postgresql', label: 'SQLAlchemy' },
|
||||
{ source: 'control-pipeline', target: 'qdrant', label: 'Embedding + Dedup' },
|
||||
{ source: 'control-pipeline', target: 'ollama', label: 'LLM Dedup (qwen3.5)' },
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
'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>
|
||||
|
||||
{/* 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,156 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { ZoneBadge } from '@/components/sdk/compliance-optimizer/ZoneBadge'
|
||||
import { DimensionZoneTable } from '@/components/sdk/compliance-optimizer/DimensionZoneTable'
|
||||
import { ConfigComparison } from '@/components/sdk/compliance-optimizer/ConfigComparison'
|
||||
import { OptimizationScoreCard } from '@/components/sdk/compliance-optimizer/OptimizationScoreCard'
|
||||
|
||||
export default function OptimizationDetailPage() {
|
||||
const params = useParams()
|
||||
const id = params?.id as string
|
||||
const [data, setData] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeVariant, setActiveVariant] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
fetch(`/api/sdk/v1/maximizer/optimizations/${id}`)
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
if (loading) return <div className="max-w-6xl mx-auto p-6 text-gray-500">Laden...</div>
|
||||
if (!data) return <div className="max-w-6xl mx-auto p-6 text-red-600">Optimierung nicht gefunden.</div>
|
||||
|
||||
const maxSafe = data.max_safe_config
|
||||
const variants = data.variants || []
|
||||
const zones = data.zone_map || {}
|
||||
const controls = data.original_evaluation?.required_controls || []
|
||||
const patterns = data.original_evaluation?.required_patterns || []
|
||||
const triggered = data.original_evaluation?.triggered_rules || []
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Link href="/sdk/compliance-optimizer" className="text-sm text-blue-600 hover:underline">← Zurueck</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-1">{data.title || 'Optimierung'}</h1>
|
||||
<p className="text-sm text-gray-500">{new Date(data.created_at).toLocaleString('de-DE')} — v{data.constraint_version}</p>
|
||||
{data.assessment_id && (
|
||||
<Link href={`/sdk/use-cases/${data.assessment_id}`} className="text-sm text-purple-600 hover:underline">
|
||||
Basierend auf Assessment
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<ZoneBadge zone={data.is_compliant ? 'SAFE' : 'FORBIDDEN'} />
|
||||
</div>
|
||||
|
||||
{/* 3-Zone Summary */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">3-Zonen-Analyse</h2>
|
||||
<DimensionZoneTable zoneMap={zones} />
|
||||
</div>
|
||||
|
||||
{/* Optimization Result */}
|
||||
{maxSafe && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">Optimierte Konfiguration</h2>
|
||||
<OptimizationScoreCard
|
||||
safetyScore={maxSafe.safety_score}
|
||||
utilityScore={maxSafe.utility_score}
|
||||
compositeScore={maxSafe.composite_score}
|
||||
deltaCount={maxSafe.delta_count}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<ConfigComparison deltas={maxSafe.deltas || []} />
|
||||
</div>
|
||||
{maxSafe.rationale && (
|
||||
<p className="mt-3 text-sm text-gray-600 italic">{maxSafe.rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alternative Variants */}
|
||||
{variants.length > 1 && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">Alternative Varianten ({variants.length})</h2>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{variants.map((v: any, i: number) => (
|
||||
<button key={i} onClick={() => setActiveVariant(i)}
|
||||
className={`px-3 py-1 text-sm rounded ${i === activeVariant ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}`}>
|
||||
Variante {i + 1}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{variants[activeVariant] && (
|
||||
<div>
|
||||
<div className="flex items-center gap-4 mb-2 text-sm text-gray-600">
|
||||
<span>Sicherheit: {variants[activeVariant].safety_score}</span>
|
||||
<span>Nutzen: {variants[activeVariant].utility_score}</span>
|
||||
<span>Gesamt: {Math.round(variants[activeVariant].composite_score)}</span>
|
||||
</div>
|
||||
<ConfigComparison deltas={variants[activeVariant].deltas || []} />
|
||||
{variants[activeVariant].rationale && (
|
||||
<p className="mt-2 text-sm text-gray-500 italic">{variants[activeVariant].rationale}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required Controls & Patterns */}
|
||||
{(controls.length > 0 || patterns.length > 0) && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">Erforderliche Massnahmen</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{controls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Controls</h4>
|
||||
<ul className="space-y-1">
|
||||
{controls.map((c: string, i: number) => (
|
||||
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-blue-500 rounded-full" />{c}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{patterns.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Architektur-Patterns</h4>
|
||||
<ul className="space-y-1">
|
||||
{patterns.map((p: string, i: number) => (
|
||||
<li key={i} className="text-sm text-gray-600 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-purple-500 rounded-full" />{p}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Triggered Rules (Audit Trail) */}
|
||||
{triggered.length > 0 && (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">Ausgeloeste Regeln ({triggered.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{triggered.map((r: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-3 text-sm border-b border-gray-100 pb-2">
|
||||
<span className="font-mono text-xs text-gray-400 min-w-[120px]">{r.rule_id}</span>
|
||||
<span className="text-gray-700">{r.title}</span>
|
||||
<span className="text-gray-400 ml-auto text-xs">{r.article_ref}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { ZoneBadge } from '@/components/sdk/compliance-optimizer/ZoneBadge'
|
||||
|
||||
interface DimensionField {
|
||||
key: string
|
||||
label: string
|
||||
options: { value: string; label: string }[]
|
||||
type?: 'select' | 'toggle'
|
||||
}
|
||||
|
||||
const DIMENSIONS: DimensionField[] = [
|
||||
{ key: 'automation_level', label: 'Automatisierungsgrad', options: [
|
||||
{ value: 'none', label: 'Keine' }, { value: 'assistive', label: 'Assistierend' },
|
||||
{ value: 'partial', label: 'Teilautomatisiert' }, { value: 'full', label: 'Vollautomatisiert' },
|
||||
]},
|
||||
{ key: 'decision_binding', label: 'Entscheidungsbindung', options: [
|
||||
{ value: 'non_binding', label: 'Unverbindlich' }, { value: 'human_review_required', label: 'Mensch entscheidet' },
|
||||
{ value: 'fully_binding', label: 'Vollstaendig bindend' },
|
||||
]},
|
||||
{ key: 'decision_impact', label: 'Entscheidungswirkung', options: [
|
||||
{ value: 'low', label: 'Niedrig' }, { value: 'medium', label: 'Mittel' }, { value: 'high', label: 'Hoch' },
|
||||
]},
|
||||
{ key: 'domain', label: 'Branche', options: [
|
||||
{ value: 'hr', label: 'HR / Personal' }, { value: 'finance', label: 'Finanzen' },
|
||||
{ value: 'education', label: 'Bildung' }, { value: 'health', label: 'Gesundheit' },
|
||||
{ value: 'marketing', label: 'Marketing' }, { value: 'general', label: 'Allgemein' },
|
||||
]},
|
||||
{ key: 'data_type', label: 'Datensensitivitaet', options: [
|
||||
{ value: 'non_personal', label: 'Keine personenbezogenen' }, { value: 'personal', label: 'Personenbezogen' },
|
||||
{ value: 'sensitive', label: 'Besondere Kategorien (Art. 9)' }, { value: 'biometric', label: 'Biometrisch' },
|
||||
]},
|
||||
{ key: 'human_in_loop', label: 'Menschliche Kontrolle', options: [
|
||||
{ value: 'required', label: 'Erforderlich' }, { value: 'optional', label: 'Optional' }, { value: 'none', label: 'Keine' },
|
||||
]},
|
||||
{ key: 'explainability', label: 'Erklaerbarkeit', options: [
|
||||
{ value: 'high', label: 'Hoch' }, { value: 'basic', label: 'Basis' }, { value: 'none', label: 'Keine' },
|
||||
]},
|
||||
{ key: 'risk_classification', label: 'Risikoklasse (AI Act)', options: [
|
||||
{ value: 'minimal', label: 'Minimal' }, { value: 'limited', label: 'Begrenzt' },
|
||||
{ value: 'high', label: 'Hoch' }, { value: 'prohibited', label: 'Verboten' },
|
||||
]},
|
||||
{ key: 'legal_basis', label: 'Rechtsgrundlage (DSGVO)', options: [
|
||||
{ value: 'consent', label: 'Einwilligung' }, { value: 'contract', label: 'Vertrag' },
|
||||
{ value: 'legal_obligation', label: 'Rechtl. Verpflichtung' },
|
||||
{ value: 'legitimate_interest', label: 'Berechtigtes Interesse' },
|
||||
{ value: 'public_interest', label: 'Oeffentl. Interesse' },
|
||||
]},
|
||||
{ key: 'model_type', label: 'Modelltyp', options: [
|
||||
{ value: 'rule_based', label: 'Regelbasiert' }, { value: 'statistical', label: 'Statistisch / ML' },
|
||||
{ value: 'blackbox_llm', label: 'Blackbox / LLM' },
|
||||
]},
|
||||
{ key: 'deployment_scope', label: 'Einsatzbereich', options: [
|
||||
{ value: 'internal', label: 'Intern' }, { value: 'external', label: 'Extern (Kunden)' },
|
||||
{ value: 'public', label: 'Oeffentlich' },
|
||||
]},
|
||||
]
|
||||
|
||||
const TOGGLE_DIMENSIONS = [
|
||||
{ key: 'transparency_required', label: 'Transparenzpflicht' },
|
||||
{ key: 'logging_required', label: 'Protokollierungspflicht' },
|
||||
]
|
||||
|
||||
function NewOptimizationPageInner() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const fromAssessment = searchParams.get('from_assessment')
|
||||
const [autoOptimizing, setAutoOptimizing] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!fromAssessment) return
|
||||
setAutoOptimizing(true)
|
||||
fetch(`/api/sdk/v1/maximizer/optimize-from-assessment/${fromAssessment}`, { method: 'POST' })
|
||||
.then(r => r.ok ? r.json() : Promise.reject('failed'))
|
||||
.then(data => router.push(`/sdk/compliance-optimizer/${data.id}`))
|
||||
.catch(() => setAutoOptimizing(false))
|
||||
}, [fromAssessment, router])
|
||||
const [config, setConfig] = useState<Record<string, string>>({
|
||||
automation_level: 'assistive', decision_binding: 'non_binding', decision_impact: 'low',
|
||||
domain: 'general', data_type: 'non_personal', human_in_loop: 'required',
|
||||
explainability: 'basic', risk_classification: 'minimal', legal_basis: 'contract',
|
||||
transparency_required: 'false', logging_required: 'false',
|
||||
model_type: 'rule_based', deployment_scope: 'internal',
|
||||
})
|
||||
const [preview, setPreview] = useState<Record<string, { zone: string }> | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
async function handlePreview() {
|
||||
try {
|
||||
const body = { ...config, transparency_required: config.transparency_required === 'true', logging_required: config.logging_required === 'true' }
|
||||
const res = await fetch('/api/sdk/v1/maximizer/evaluate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setPreview(data.zone_map || {})
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const body = {
|
||||
config: { ...config, transparency_required: config.transparency_required === 'true', logging_required: config.logging_required === 'true' },
|
||||
title: title || 'Optimierung ' + new Date().toLocaleDateString('de-DE'),
|
||||
}
|
||||
const res = await fetch('/api/sdk/v1/maximizer/optimize', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
router.push(`/sdk/compliance-optimizer/${data.id}`)
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (autoOptimizing) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 text-center py-24">
|
||||
<div className="animate-pulse">
|
||||
<span className="text-4xl">📊</span>
|
||||
<h2 className="text-xl font-bold text-gray-900 mt-4 mb-2">Optimierung laeuft...</h2>
|
||||
<p className="text-sm text-gray-500">Assessment wird analysiert und optimale Konfiguration berechnet.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-1">Neue Optimierung</h1>
|
||||
<p className="text-sm text-gray-500 mb-6">Konfigurieren Sie Ihren KI-Use-Case und finden Sie den maximalen regulatorischen Spielraum.</p>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
|
||||
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="z.B. HR Bewerber-Ranking"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
{DIMENSIONS.map((dim) => (
|
||||
<div key={dim.key}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{dim.label}
|
||||
{preview && preview[dim.key] && (
|
||||
<span className="ml-2"><ZoneBadge zone={preview[dim.key].zone as 'FORBIDDEN' | 'RESTRICTED' | 'SAFE'} /></span>
|
||||
)}
|
||||
</label>
|
||||
<select
|
||||
value={config[dim.key]}
|
||||
onChange={(e) => { setConfig({ ...config, [dim.key]: e.target.value }); setPreview(null) }}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white"
|
||||
>
|
||||
{dim.options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{TOGGLE_DIMENSIONS.map((dim) => (
|
||||
<div key={dim.key} className="flex items-center gap-3">
|
||||
<input type="checkbox" checked={config[dim.key] === 'true'}
|
||||
onChange={(e) => { setConfig({ ...config, [dim.key]: String(e.target.checked) }); setPreview(null) }}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600" />
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
{dim.label}
|
||||
{preview && preview[dim.key] && (
|
||||
<span className="ml-2"><ZoneBadge zone={preview[dim.key].zone as 'FORBIDDEN' | 'RESTRICTED' | 'SAFE'} /></span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button onClick={handlePreview} className="border border-gray-300 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-50 text-sm">
|
||||
Vorschau (3-Zonen-Check)
|
||||
</button>
|
||||
<button onClick={handleSubmit} disabled={submitting}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 text-sm font-medium disabled:opacity-50">
|
||||
{submitting ? 'Optimiere...' : 'Optimieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewOptimizationPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="max-w-4xl mx-auto p-6 text-gray-500">Laden...</div>}>
|
||||
<NewOptimizationPageInner />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ZoneBadge } from '@/components/sdk/compliance-optimizer/ZoneBadge'
|
||||
|
||||
interface OptimizationSummary {
|
||||
id: string
|
||||
title: string
|
||||
is_compliant: boolean
|
||||
constraint_version: string
|
||||
created_at: string
|
||||
zone_map: Record<string, { zone: 'FORBIDDEN' | 'RESTRICTED' | 'SAFE' }>
|
||||
max_safe_config?: { safety_score: number; utility_score: number }
|
||||
assessment_id?: string
|
||||
}
|
||||
|
||||
function countZones(zoneMap: Record<string, { zone: string }>) {
|
||||
let forbidden = 0, restricted = 0, safe = 0
|
||||
for (const v of Object.values(zoneMap || {})) {
|
||||
if (v.zone === 'FORBIDDEN') forbidden++
|
||||
else if (v.zone === 'RESTRICTED') restricted++
|
||||
else safe++
|
||||
}
|
||||
return { forbidden, restricted, safe }
|
||||
}
|
||||
|
||||
export default function ComplianceOptimizerPage() {
|
||||
const [optimizations, setOptimizations] = useState<OptimizationSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptimizations()
|
||||
}, [])
|
||||
|
||||
async function fetchOptimizations() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch('/api/sdk/v1/maximizer/optimizations?limit=20')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setOptimizations(data.optimizations || [])
|
||||
setTotal(data.total || 0)
|
||||
}
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Compliance Optimizer</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Regulatorischen Spielraum maximieren — KI-Use-Cases optimal konfigurieren
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/sdk/compliance-optimizer/new"
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 text-sm font-medium"
|
||||
>
|
||||
Neue Optimierung
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Laden...</div>
|
||||
) : optimizations.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<p className="text-gray-600 mb-2">Noch keine Optimierungen durchgefuehrt.</p>
|
||||
<Link href="/sdk/compliance-optimizer/new" className="text-blue-600 hover:underline text-sm">
|
||||
Erste Optimierung starten
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Titel</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">Zonen</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quelle</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Datum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{optimizations.map((o) => {
|
||||
const zones = countZones(o.zone_map)
|
||||
return (
|
||||
<tr key={o.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/sdk/compliance-optimizer/${o.id}`} className="text-blue-600 hover:underline font-medium text-sm">
|
||||
{o.title || 'Ohne Titel'}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ZoneBadge zone={o.is_compliant ? 'SAFE' : 'FORBIDDEN'} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">
|
||||
{zones.forbidden > 0 && <span className="text-red-600 mr-2">{zones.forbidden} verboten</span>}
|
||||
{zones.restricted > 0 && <span className="text-yellow-600 mr-2">{zones.restricted} eingeschraenkt</span>}
|
||||
<span className="text-green-600">{zones.safe} erlaubt</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{o.assessment_id ? (
|
||||
<Link href={`/sdk/use-cases/${o.assessment_id}`} className="text-purple-600 hover:underline text-xs">
|
||||
Assessment
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-gray-400 text-xs">Manuell</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(o.created_at).toLocaleDateString('de-DE')}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{total > 20 && (
|
||||
<div className="px-4 py-3 bg-gray-50 text-sm text-gray-500">
|
||||
{total} Optimierungen insgesamt
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
interface DeadlineConfig {
|
||||
gracePeriodDays: number
|
||||
reminderDays: number[]
|
||||
suspendOnExpiry: boolean
|
||||
}
|
||||
|
||||
export function DeadlineTab() {
|
||||
// Phase 4: Deadline management — backend service pending (Core integration)
|
||||
const config: DeadlineConfig = {
|
||||
gracePeriodDays: 30,
|
||||
reminderDays: [28, 21, 14, 7],
|
||||
suspendOnExpiry: true,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Fristen & Erinnerungen</h2>
|
||||
<span className="px-2 py-1 bg-yellow-100 text-yellow-700 rounded text-xs">In Vorbereitung</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800">
|
||||
Das Fristen-System wird automatisch Erinnerungen an Nutzer senden, die neue Pflichtdokumente
|
||||
noch nicht akzeptiert haben. Nach Ablauf der Frist wird der Account gesperrt bis die Zustimmung erfolgt.
|
||||
Die E-Mail-Zustellung wird ueber den Core-Service in Production bereitgestellt.
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700">Nachfrist</h3>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{config.gracePeriodDays} Tage</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Nach Veroeffentlichung eines Pflichtdokuments</p>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700">Erinnerungen</h3>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{config.reminderDays.length}x</p>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
Tag {config.reminderDays.join(', ')} nach Veroeffentlichung
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700">Auto-Sperrung</h3>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{config.suspendOnExpiry ? 'Aktiv' : 'Inaktiv'}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Account wird nach Fristablauf gesperrt</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-3">Erinnerungs-Timeline</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 30 }, (_, i) => {
|
||||
const day = 30 - i
|
||||
const isReminder = config.reminderDays.includes(day)
|
||||
const isDeadline = day === 0
|
||||
return (
|
||||
<div key={i} className="flex-1 relative group">
|
||||
<div className={`h-2 rounded-sm ${
|
||||
isDeadline ? 'bg-red-500' : isReminder ? 'bg-yellow-400' : 'bg-slate-100'
|
||||
}`} />
|
||||
{isReminder && (
|
||||
<span className="absolute -top-5 left-1/2 -translate-x-1/2 text-[10px] text-yellow-600 whitespace-nowrap">
|
||||
Tag {day}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="flex-none w-3 h-2 bg-red-500 rounded-sm" title="Sperrung" />
|
||||
</div>
|
||||
<div className="flex justify-between mt-1 text-[10px] text-slate-400">
|
||||
<span>Veroeffentlichung</span>
|
||||
<span>Sperrung</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link href="/sdk/email-templates" className="text-sm text-purple-600 hover:underline">
|
||||
E-Mail-Templates konfigurieren →
|
||||
</Link>
|
||||
<Link href="/sdk/dsr" className="text-sm text-purple-600 hover:underline">
|
||||
Betroffenenrechte verwalten →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
const INTEGRATIONS = [
|
||||
{
|
||||
id: 'matrix',
|
||||
name: 'Matrix Kommunikation',
|
||||
description: 'Sichere, verschluesselte Kommunikation mit Betroffenen ueber Matrix-Protokoll. Wird in Production ueber den Core Communication Service bereitgestellt.',
|
||||
status: 'planned',
|
||||
icon: '💬',
|
||||
},
|
||||
{
|
||||
id: 'jitsi',
|
||||
name: 'Jitsi Video-Meetings',
|
||||
description: 'DSGVO-konforme Video-Konsultationen mit Betroffenen fuer komplexe Datenschutzanfragen. Wird ueber den Core Jitsi Service bereitgestellt.',
|
||||
status: 'planned',
|
||||
icon: '📹',
|
||||
},
|
||||
{
|
||||
id: 'oauth',
|
||||
name: 'OAuth 2.0 Client-Verwaltung',
|
||||
description: 'Verwaltung von OAuth-Clients fuer API-Zugriff auf Consent-Endpunkte. Authorization Code Flow mit PKCE-Support.',
|
||||
status: 'planned',
|
||||
icon: '🔑',
|
||||
},
|
||||
{
|
||||
id: '2fa',
|
||||
name: 'Zwei-Faktor-Authentifizierung',
|
||||
description: 'TOTP-basierte Zwei-Faktor-Authentifizierung fuer Admin-Zugang. Recovery-Codes fuer Notfallzugriff.',
|
||||
status: 'planned',
|
||||
icon: '🛡️',
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
name: 'Benachrichtigungssystem',
|
||||
description: 'In-App und E-Mail Benachrichtigungen fuer Consent-Aenderungen, DSR-Fristen und Dokument-Updates. Praeferenz-Verwaltung pro Nutzer.',
|
||||
status: 'planned',
|
||||
icon: '🔔',
|
||||
},
|
||||
]
|
||||
|
||||
export function IntegrationStubs() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Integrationen</h2>
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 rounded text-xs">Production-Anbindung</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
Diese Dienste werden in Production ueber die Core-Services bereitgestellt und sind
|
||||
im SDK vorbereitet.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{INTEGRATIONS.map(integration => (
|
||||
<div key={integration.id} className="border border-slate-200 rounded-lg p-4 bg-slate-50">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">{integration.icon}</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-slate-800">{integration.name}</h3>
|
||||
<span className="px-1.5 py-0.5 bg-yellow-100 text-yellow-700 rounded text-[10px]">Geplant</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1">{integration.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,18 +2,28 @@
|
||||
|
||||
import type { Document, Version } from '../_types'
|
||||
|
||||
const STATUS_STYLES: Record<string, { label: string; color: string }> = {
|
||||
draft: { label: 'Entwurf', color: 'bg-gray-100 text-gray-700' },
|
||||
review: { label: 'Pruefung', color: 'bg-yellow-100 text-yellow-700' },
|
||||
approved: { label: 'Genehmigt', color: 'bg-blue-100 text-blue-700' },
|
||||
published: { label: 'Publiziert', color: 'bg-green-100 text-green-700' },
|
||||
archived: { label: 'Archiviert', color: 'bg-gray-100 text-gray-400' },
|
||||
rejected: { label: 'Abgelehnt', color: 'bg-red-100 text-red-700' },
|
||||
}
|
||||
|
||||
export function VersionsTab({
|
||||
loading,
|
||||
documents,
|
||||
versions,
|
||||
selectedDocument,
|
||||
setSelectedDocument,
|
||||
loading, documents, versions, selectedDocument, setSelectedDocument,
|
||||
onSubmitReview, onApprove, onReject, onPublish,
|
||||
}: {
|
||||
loading: boolean
|
||||
documents: Document[]
|
||||
versions: Version[]
|
||||
selectedDocument: string
|
||||
setSelectedDocument: (id: string) => void
|
||||
onSubmitReview?: (versionId: string) => void
|
||||
onApprove?: (versionId: string) => void
|
||||
onReject?: (versionId: string, comment: string) => void
|
||||
onPublish?: (versionId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
@@ -27,73 +37,69 @@ export function VersionsTab({
|
||||
>
|
||||
<option value="">Dokument auswaehlen...</option>
|
||||
{documents.map((doc) => (
|
||||
<option key={doc.id} value={doc.id}>
|
||||
{doc.name}
|
||||
</option>
|
||||
<option key={doc.id} value={doc.id}>{doc.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedDocument && (
|
||||
<button className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors text-sm font-medium">
|
||||
+ Neue Version
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!selectedDocument ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Bitte waehlen Sie ein Dokument aus
|
||||
</div>
|
||||
<div className="text-center py-12 text-slate-500">Bitte waehlen Sie ein Dokument aus</div>
|
||||
) : loading ? (
|
||||
<div className="text-center py-12 text-slate-500">Lade Versionen...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
Keine Versionen vorhanden
|
||||
</div>
|
||||
<div className="text-center py-12 text-slate-500">Keine Versionen vorhanden</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{versions.map((version) => (
|
||||
<div
|
||||
key={version.id}
|
||||
className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{version.language.toUpperCase()}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
version.status === 'published'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: version.status === 'draft'
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{version.status}
|
||||
</span>
|
||||
{versions.map((version) => {
|
||||
const style = STATUS_STYLES[version.status] || STATUS_STYLES.draft
|
||||
return (
|
||||
<div key={version.id} className="border border-slate-200 rounded-lg p-4 hover:border-purple-300 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-slate-900">v{version.version}</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-xs">
|
||||
{version.language.toUpperCase()}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${style.color}`}>{style.label}</span>
|
||||
</div>
|
||||
<h3 className="text-slate-700">{version.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
|
||||
{version.published_at && ` | Publiziert: ${new Date(version.published_at).toLocaleDateString('de-DE')}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap justify-end">
|
||||
{version.status === 'draft' && onSubmitReview && (
|
||||
<button onClick={() => onSubmitReview(version.id)}
|
||||
className="px-3 py-1.5 text-sm text-white bg-yellow-500 hover:bg-yellow-600 rounded-lg">
|
||||
Zur Pruefung
|
||||
</button>
|
||||
)}
|
||||
{version.status === 'review' && onApprove && (
|
||||
<button onClick={() => onApprove(version.id)}
|
||||
className="px-3 py-1.5 text-sm text-white bg-blue-600 hover:bg-blue-700 rounded-lg">
|
||||
Genehmigen
|
||||
</button>
|
||||
)}
|
||||
{version.status === 'review' && onReject && (
|
||||
<button onClick={() => { const c = prompt('Ablehnungsgrund:'); if (c) onReject(version.id, c) }}
|
||||
className="px-3 py-1.5 text-sm text-white bg-red-500 hover:bg-red-600 rounded-lg">
|
||||
Ablehnen
|
||||
</button>
|
||||
)}
|
||||
{version.status === 'approved' && onPublish && (
|
||||
<button onClick={() => onPublish(version.id)}
|
||||
className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
|
||||
Publizieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-slate-700">{version.title}</h3>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Erstellt: {new Date(version.created_at).toLocaleDateString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-900 border border-slate-300 rounded-lg hover:border-slate-400">
|
||||
Bearbeiten
|
||||
</button>
|
||||
{version.status === 'draft' && (
|
||||
<button className="px-3 py-1.5 text-sm text-white bg-green-600 hover:bg-green-700 rounded-lg">
|
||||
Veroeffentlichen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -277,6 +277,45 @@ export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
||||
localStorage.setItem('sdk-email-templates', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
// Document version workflow actions (via admin consent proxy → legal-documents backend)
|
||||
async function submitVersionForReview(versionId: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/submit-review`, {
|
||||
method: 'POST', headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
||||
})
|
||||
if (res.ok && selectedDocument) await loadVersions(selectedDocument)
|
||||
} catch (err) { console.error('Submit failed:', err) }
|
||||
}
|
||||
|
||||
async function approveVersion(versionId: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/approve`, {
|
||||
method: 'POST', headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
||||
})
|
||||
if (res.ok && selectedDocument) await loadVersions(selectedDocument)
|
||||
} catch (err) { console.error('Approve failed:', err) }
|
||||
}
|
||||
|
||||
async function rejectVersion(versionId: string, comment: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/reject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(authToken ? { 'Authorization': `Bearer ${authToken}` } : {}) },
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
if (res.ok && selectedDocument) await loadVersions(selectedDocument)
|
||||
} catch (err) { console.error('Reject failed:', err) }
|
||||
}
|
||||
|
||||
async function publishVersion(versionId: string) {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/publish`, {
|
||||
method: 'POST', headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
|
||||
})
|
||||
if (res.ok) { if (selectedDocument) await loadVersions(selectedDocument); await loadDocuments() }
|
||||
} catch (err) { console.error('Publish failed:', err) }
|
||||
}
|
||||
|
||||
return {
|
||||
documents, versions, loading, error, setError,
|
||||
consentStats, dsrCounts, dsrOverview,
|
||||
@@ -286,6 +325,7 @@ export function useConsentData(activeTab: Tab, selectedDocument: string) {
|
||||
savingTemplateId, savingProcessId,
|
||||
saveApiEmailTemplate, saveApiGdprProcess,
|
||||
loadApiEmailTemplates,
|
||||
submitVersionForReview, approveVersion, rejectVersion, publishVersion,
|
||||
authToken, setAuthToken,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const API_BASE = '/api/admin/consent'
|
||||
|
||||
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats'
|
||||
export type Tab = 'documents' | 'versions' | 'emails' | 'gdpr' | 'stats' | 'deadlines' | 'integrations'
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
|
||||
@@ -25,6 +25,8 @@ import { GdprTab } from './_components/GdprTab'
|
||||
import { StatsTab } from './_components/StatsTab'
|
||||
import { ConsentTemplateCreateModal } from './_components/ConsentTemplateCreateModal'
|
||||
import { EmailTemplateEditModal, EmailTemplatePreviewModal } from './_components/EmailTemplateModals'
|
||||
import { DeadlineTab } from './_components/DeadlineTab'
|
||||
import { IntegrationStubs } from './_components/IntegrationStubs'
|
||||
|
||||
export default function ConsentManagementPage() {
|
||||
const { state } = useSDK()
|
||||
@@ -45,6 +47,7 @@ export default function ConsentManagementPage() {
|
||||
savingTemplateId, savingProcessId,
|
||||
saveApiEmailTemplate, saveApiGdprProcess,
|
||||
loadApiEmailTemplates,
|
||||
submitVersionForReview, approveVersion, rejectVersion, publishVersion,
|
||||
authToken, setAuthToken,
|
||||
} = useConsentData(activeTab, selectedDocument)
|
||||
|
||||
@@ -54,6 +57,8 @@ export default function ConsentManagementPage() {
|
||||
{ id: 'emails', label: 'E-Mail Vorlagen' },
|
||||
{ id: 'gdpr', label: 'DSGVO Prozesse' },
|
||||
{ id: 'stats', label: 'Statistiken' },
|
||||
{ id: 'deadlines', label: 'Fristen' },
|
||||
{ id: 'integrations', label: 'Integrationen' },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -128,6 +133,10 @@ export default function ConsentManagementPage() {
|
||||
versions={versions}
|
||||
selectedDocument={selectedDocument}
|
||||
setSelectedDocument={setSelectedDocument}
|
||||
onSubmitReview={submitVersionForReview}
|
||||
onApprove={approveVersion}
|
||||
onReject={rejectVersion}
|
||||
onPublish={publishVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -157,6 +166,10 @@ export default function ConsentManagementPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'stats' && <StatsTab consentStats={consentStats} />}
|
||||
|
||||
{activeTab === 'deadlines' && <DeadlineTab />}
|
||||
|
||||
{activeTab === 'integrations' && <IntegrationStubs />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export const CATEGORIES: { key: string; label: string; types: string[] | null }[
|
||||
{ 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: [
|
||||
'dsr_process_art15', 'dsr_process_art16', 'dsr_process_art17',
|
||||
'dsr_process_art18', 'dsr_process_art19', 'dsr_process_art20', 'dsr_process_art21',
|
||||
]},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -13,20 +13,21 @@ export function LoadingSpinner() {
|
||||
)
|
||||
}
|
||||
|
||||
export { PublicFormConfig as SettingsTabContent } from './PublicFormConfig'
|
||||
|
||||
export function SettingsTab() {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<SettingsTabContent />
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-2">Workflow-Konfiguration</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
SLA-Fristen, automatische Zuweisungen und Eskalationsregeln
|
||||
werden in Production ueber den Core-Service konfiguriert.
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
|
||||
<p className="mt-2 text-gray-500">
|
||||
DSR-Portal-Einstellungen, E-Mail-Vorlagen und Workflow-Konfiguration
|
||||
werden in einer spaeteren Version verfuegbar sein.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface PublicFormSettings {
|
||||
enabled: boolean
|
||||
formUrl: string
|
||||
allowedTypes: string[]
|
||||
requireIdentity: boolean
|
||||
customCss: string
|
||||
}
|
||||
|
||||
const DSR_TYPES = [
|
||||
{ value: 'access', label: 'Auskunft (Art. 15)' },
|
||||
{ value: 'rectification', label: 'Berichtigung (Art. 16)' },
|
||||
{ value: 'erasure', label: 'Loeschung (Art. 17)' },
|
||||
{ value: 'restriction', label: 'Einschraenkung (Art. 18)' },
|
||||
{ value: 'portability', label: 'Datenportabilitaet (Art. 20)' },
|
||||
{ value: 'objection', label: 'Widerspruch (Art. 21)' },
|
||||
]
|
||||
|
||||
export function PublicFormConfig() {
|
||||
const [settings, setSettings] = useState<PublicFormSettings>({
|
||||
enabled: false,
|
||||
formUrl: '',
|
||||
allowedTypes: ['access', 'erasure', 'portability'],
|
||||
requireIdentity: true,
|
||||
customCss: '',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">Oeffentliches DSR-Formular</h3>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={settings.enabled}
|
||||
onChange={e => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
className="rounded border-gray-300 text-purple-600" />
|
||||
<span className="text-sm text-slate-600">Aktiviert</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!settings.enabled ? (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-sm text-gray-600">
|
||||
Das oeffentliche DSR-Formular ermoeglicht Betroffenen, Datenschutzanfragen direkt
|
||||
ueber Ihre Website einzureichen — ohne Anmeldung. Aktivieren Sie es, um den
|
||||
Embed-Code zu generieren.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Erlaubte Anfragetypen</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{DSR_TYPES.map(type => (
|
||||
<label key={type.value} className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input type="checkbox"
|
||||
checked={settings.allowedTypes.includes(type.value)}
|
||||
onChange={e => {
|
||||
const types = e.target.checked
|
||||
? [...settings.allowedTypes, type.value]
|
||||
: settings.allowedTypes.filter(t => t !== type.value)
|
||||
setSettings({ ...settings, allowedTypes: types })
|
||||
}}
|
||||
className="rounded border-gray-300 text-purple-600" />
|
||||
{type.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" checked={settings.requireIdentity}
|
||||
onChange={e => setSettings({ ...settings, requireIdentity: e.target.checked })}
|
||||
className="rounded border-gray-300 text-purple-600" />
|
||||
<span className="text-sm text-slate-600">Identitaetsnachweis erforderlich</span>
|
||||
</label>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">Embed-Code</h4>
|
||||
<pre className="text-xs font-mono bg-white border border-slate-200 rounded p-3 overflow-x-auto">
|
||||
{`<iframe
|
||||
src="https://ihre-domain.breakpilot.ai/dsr/public-form"
|
||||
width="100%"
|
||||
height="600"
|
||||
frameborder="0"
|
||||
title="Datenschutzanfrage"
|
||||
></iframe>`}
|
||||
</pre>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
Embed-Code wird nach Anbindung an Production generiert.
|
||||
</p>
|
||||
</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',
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
-152
@@ -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"><head></code> oder vor dem
|
||||
schliessenden{' '}
|
||||
<code className="bg-amber-100 px-1 rounded"></body></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')
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@ interface EditorTabProps {
|
||||
onPublish: () => void
|
||||
onPreview: () => void
|
||||
onBack: () => void
|
||||
onSubmitForReview?: () => void
|
||||
onApprove?: (comment?: string) => void
|
||||
onReject?: (comment: string) => void
|
||||
onSendTest?: (email: string) => void
|
||||
}
|
||||
|
||||
export function EditorTab({
|
||||
template, version, subject, html, previewHtml, saving,
|
||||
onSubjectChange, onHtmlChange, onSave, onPublish, onPreview, onBack,
|
||||
onSubmitForReview, onApprove, onReject, onSendTest,
|
||||
}: EditorTabProps) {
|
||||
if (!template) {
|
||||
return (
|
||||
@@ -46,30 +51,56 @@ export function EditorTab({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Version speichern'}
|
||||
</button>
|
||||
{version && version.status !== 'published' && (
|
||||
<button
|
||||
onClick={onPublish}
|
||||
disabled={saving}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{/* Save — always available for draft/review */}
|
||||
{(!version || version.status === 'draft' || version.status === 'review') && (
|
||||
<button onClick={onSave} disabled={saving}
|
||||
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50">
|
||||
{saving ? 'Speichern...' : 'Version speichern'}
|
||||
</button>
|
||||
)}
|
||||
{/* Submit for Review — only for draft */}
|
||||
{version && version.status === 'draft' && onSubmitForReview && (
|
||||
<button onClick={onSubmitForReview} disabled={saving}
|
||||
className="px-3 py-1.5 bg-yellow-500 text-white rounded-lg text-sm hover:bg-yellow-600 disabled:opacity-50">
|
||||
Zur Pruefung einreichen
|
||||
</button>
|
||||
)}
|
||||
{/* Approve — only for review status (DSB) */}
|
||||
{version && version.status === 'review' && onApprove && (
|
||||
<button onClick={() => onApprove()} disabled={saving}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50">
|
||||
Genehmigen
|
||||
</button>
|
||||
)}
|
||||
{/* Reject — only for review status (DSB) */}
|
||||
{version && version.status === 'review' && onReject && (
|
||||
<button onClick={() => { const c = prompt('Ablehnungsgrund:'); if (c) onReject(c) }} disabled={saving}
|
||||
className="px-3 py-1.5 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600 disabled:opacity-50">
|
||||
Ablehnen
|
||||
</button>
|
||||
)}
|
||||
{/* Publish — only for approved */}
|
||||
{version && version.status === 'approved' && (
|
||||
<button onClick={onPublish} disabled={saving}
|
||||
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 disabled:opacity-50">
|
||||
Publizieren
|
||||
</button>
|
||||
)}
|
||||
{/* Preview + Test — always when version exists */}
|
||||
{version && (
|
||||
<button
|
||||
onClick={onPreview}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50"
|
||||
>
|
||||
Vorschau
|
||||
</button>
|
||||
<>
|
||||
<button onClick={onPreview}
|
||||
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50">
|
||||
Vorschau
|
||||
</button>
|
||||
{onSendTest && (
|
||||
<button onClick={() => { const e = prompt('Test-E-Mail an:'); if (e) onSendTest(e) }}
|
||||
className="px-3 py-1.5 border border-blue-300 text-blue-700 rounded-lg text-sm hover:bg-blue-50">
|
||||
Test senden
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +108,7 @@ export function EditorTab({
|
||||
{/* Variables */}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<span className="text-xs text-gray-500 mr-1">Variablen:</span>
|
||||
{(template.variables || []).map(v => (
|
||||
{(Array.isArray(template.variables) ? template.variables : []).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onHtmlChange(html + `{{${v}}}`)}
|
||||
|
||||
@@ -30,12 +30,12 @@ export function TemplateCard({ template, onEdit }: TemplateCardProps) {
|
||||
<p className="text-xs text-gray-500 mt-2 line-clamp-2">{template.description}</p>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{(template.variables || []).slice(0, 4).map(v => (
|
||||
{(Array.isArray(template.variables) ? template.variables : []).slice(0, 4).map(v => (
|
||||
<span key={v} className="px-1.5 py-0.5 bg-gray-50 text-gray-500 rounded text-xs font-mono">
|
||||
{`{{${v}}}`}
|
||||
</span>
|
||||
))}
|
||||
{(template.variables || []).length > 4 && (
|
||||
{Array.isArray(template.variables) && template.variables.length > 4 && (
|
||||
<span className="text-xs text-gray-400">+{template.variables.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
SendLog,
|
||||
Settings,
|
||||
TabId,
|
||||
TemplateApproval,
|
||||
TemplateType,
|
||||
TemplateVersion,
|
||||
getHeaders,
|
||||
@@ -194,6 +195,72 @@ export function useEmailTemplates(activeTab: TabId) {
|
||||
}
|
||||
}, [settingsForm])
|
||||
|
||||
// Workflow actions
|
||||
const submitForReview = useCallback(async () => {
|
||||
if (!editorVersion) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/submit`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated = await res.json()
|
||||
setEditorVersion(updated)
|
||||
await loadTemplates()
|
||||
} catch (e: any) { setError(e.message) } finally { setSaving(false) }
|
||||
}, [editorVersion, loadTemplates])
|
||||
|
||||
const approveVersion = useCallback(async (comment?: string) => {
|
||||
if (!editorVersion) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/approve`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
body: JSON.stringify({ comment: comment || '' }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated = await res.json()
|
||||
setEditorVersion(updated)
|
||||
await loadTemplates()
|
||||
} catch (e: any) { setError(e.message) } finally { setSaving(false) }
|
||||
}, [editorVersion, loadTemplates])
|
||||
|
||||
const rejectVersion = useCallback(async (comment: string) => {
|
||||
if (!editorVersion) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/reject`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
body: JSON.stringify({ comment }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const updated = await res.json()
|
||||
setEditorVersion(updated)
|
||||
await loadTemplates()
|
||||
} catch (e: any) { setError(e.message) } finally { setSaving(false) }
|
||||
}, [editorVersion, loadTemplates])
|
||||
|
||||
const sendTestEmail = useCallback(async (recipientEmail: string) => {
|
||||
if (!editorVersion) return
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/send-test`, {
|
||||
method: 'POST', headers: getHeaders(),
|
||||
body: JSON.stringify({ recipient: recipientEmail }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return await res.json()
|
||||
} catch (e: any) { setError(e.message) }
|
||||
}, [editorVersion])
|
||||
|
||||
const loadApprovalHistory = useCallback(async (versionId: string): Promise<TemplateApproval[]> => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/versions/${versionId}/approvals`, { headers: getHeaders() })
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
return Array.isArray(data) ? data : data.approvals || []
|
||||
} catch { return [] }
|
||||
}, [])
|
||||
|
||||
const initializeDefaults = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/initialize`, {
|
||||
@@ -222,6 +289,8 @@ export function useEmailTemplates(activeTab: TabId) {
|
||||
setSettingsForm,
|
||||
// Actions
|
||||
openEditor, saveVersion, publishVersion, loadPreview,
|
||||
submitForReview, approveVersion, rejectVersion,
|
||||
sendTestEmail, loadApprovalHistory,
|
||||
saveSettings2, initializeDefaults,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,16 @@ export interface SendLog {
|
||||
sent_at: string | null
|
||||
}
|
||||
|
||||
export interface TemplateApproval {
|
||||
id: string
|
||||
version_id: string
|
||||
action: string // submitted, approved, rejected
|
||||
actor_id: string
|
||||
actor_name?: string
|
||||
comment?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
sender_name: string
|
||||
sender_email: string
|
||||
|
||||
@@ -22,6 +22,8 @@ export default function EmailTemplatesPage() {
|
||||
setEditorSubject, setEditorHtml,
|
||||
setSettingsForm,
|
||||
openEditor, saveVersion, publishVersion, loadPreview,
|
||||
submitForReview, approveVersion, rejectVersion,
|
||||
sendTestEmail, loadApprovalHistory,
|
||||
saveSettings2, initializeDefaults,
|
||||
} = useEmailTemplates(activeTab)
|
||||
|
||||
@@ -68,6 +70,10 @@ export default function EmailTemplatesPage() {
|
||||
onPublish={publishVersion}
|
||||
onPreview={loadPreview}
|
||||
onBack={() => setActiveTab('templates')}
|
||||
onSubmitForReview={submitForReview}
|
||||
onApprove={approveVersion}
|
||||
onReject={rejectVersion}
|
||||
onSendTest={sendTestEmail}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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">
|
||||
{/* 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'
|
||||
: '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="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>
|
||||
)
|
||||
}
|
||||
@@ -55,9 +55,9 @@ export default function ComponentsPage() {
|
||||
Erfassen Sie alle Software-, Firmware-, KI- und Hardware-Komponenten der Maschine.
|
||||
</p>
|
||||
</div>
|
||||
{!c.showForm && (
|
||||
{!showForm && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => c.setShowLibrary(true)}
|
||||
<button onClick={() => setShowLibrary(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" 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" />
|
||||
@@ -96,7 +96,7 @@ export default function ComponentsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{c.tree.length > 0 ? (
|
||||
{tree.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-750 rounded-t-xl">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
@@ -108,14 +108,14 @@ export default function ComponentsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{c.tree.map((component) => (
|
||||
{tree.map((component) => (
|
||||
<ComponentTreeNode key={component.id} component={component} depth={0}
|
||||
onEdit={c.handleEdit} onDelete={c.handleDelete} onAddChild={c.handleAddChild} />
|
||||
onEdit={handleEdit} onDelete={handleDelete} onAddChild={handleAddChild} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
!c.showForm && (
|
||||
!showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -128,11 +128,11 @@ export default function ComponentsPage() {
|
||||
Erstellen Sie eine hierarchische Struktur aus Software, Firmware, KI-Modulen und Hardware.
|
||||
</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<button onClick={() => c.setShowLibrary(true)}
|
||||
<button onClick={() => setShowLibrary(true)}
|
||||
className="px-6 py-3 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors">
|
||||
Aus Bibliothek waehlen
|
||||
</button>
|
||||
<button onClick={() => c.setShowForm(true)}
|
||||
<button onClick={() => setShowForm(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
Manuell hinzufuegen
|
||||
</button>
|
||||
|
||||
@@ -11,13 +11,13 @@ export function AutoSuggestPanel({ matchResult, applying, onApply, onClose }: {
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [selectedHazards, setSelectedHazards] = useState<Set<string>>(
|
||||
new Set(matchResult.suggested_hazards.map(h => h.category))
|
||||
new Set((matchResult.suggested_hazards || []).map(h => h.category))
|
||||
)
|
||||
const [selectedMeasures, setSelectedMeasures] = useState<Set<string>>(
|
||||
new Set(matchResult.suggested_measures.map(m => m.measure_id))
|
||||
new Set((matchResult.suggested_measures || []).map(m => m.measure_id))
|
||||
)
|
||||
const [selectedEvidence, setSelectedEvidence] = useState<Set<string>>(
|
||||
new Set(matchResult.suggested_evidence.map(e => e.evidence_id))
|
||||
new Set((matchResult.suggested_evidence || []).map(e => e.evidence_id))
|
||||
)
|
||||
|
||||
function toggle<T>(set: Set<T>, setSet: (s: Set<T>) => void, key: T) {
|
||||
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Hazard, CATEGORY_LABELS, getRiskColor, getRiskLevelLabel, getRiskLevelISO,
|
||||
} from './types'
|
||||
|
||||
interface RiskAssessmentTableProps {
|
||||
projectId: string
|
||||
hazards: Hazard[]
|
||||
onReassess?: () => void
|
||||
}
|
||||
|
||||
/** Editable S/E/P/A state per hazard for the "after measures" column. */
|
||||
interface EditState {
|
||||
severity: number; exposure: number; probability: number; avoidance: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function rpz(s: number, e: number, p: number, a: number): number {
|
||||
return a >= 1 ? s * e * p * a : s * e * p
|
||||
}
|
||||
|
||||
function plFromRpz(r: number): string {
|
||||
if (r > 300) return 'e'
|
||||
if (r >= 151) return 'd'
|
||||
if (r >= 61) return 'c'
|
||||
if (r >= 21) return 'b'
|
||||
return 'a'
|
||||
}
|
||||
|
||||
function silFromRpz(r: number): number {
|
||||
if (r > 300) return 3
|
||||
if (r >= 151) return 2
|
||||
if (r >= 61) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
const PL_COLORS: Record<string, string> = {
|
||||
e: 'bg-red-100 text-red-800', d: 'bg-orange-100 text-orange-800',
|
||||
c: 'bg-yellow-100 text-yellow-800', b: 'bg-green-100 text-green-800',
|
||||
a: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
const SIL_COLORS: Record<number, string> = {
|
||||
3: 'bg-red-100 text-red-800', 2: 'bg-orange-100 text-orange-800',
|
||||
1: 'bg-yellow-100 text-yellow-800', 0: 'bg-gray-100 text-gray-600',
|
||||
}
|
||||
|
||||
const VALUES = [1, 2, 3, 4, 5]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline editable dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function InlineSelect({ value, onChange, label }: {
|
||||
value: number; onChange: (v: number) => void; label: string
|
||||
}) {
|
||||
return (
|
||||
<select value={value} onChange={e => onChange(Number(e.target.value))}
|
||||
aria-label={label}
|
||||
className="w-12 text-center text-xs border border-gray-300 rounded bg-white dark:bg-gray-700 dark:border-gray-600 dark:text-white py-0.5 focus:ring-1 focus:ring-purple-400 focus:border-purple-400">
|
||||
{VALUES.map(v => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function RiskAssessmentTable({ projectId, hazards, onReassess }: RiskAssessmentTableProps) {
|
||||
const [mitCounts, setMitCounts] = useState<Record<string, number>>({})
|
||||
const [edits, setEdits] = useState<Record<string, EditState>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
// Fetch mitigation counts per hazard
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`)
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const mits: { hazard_id: string }[] = json.mitigations || json || []
|
||||
const counts: Record<string, number> = {}
|
||||
for (const m of mits) {
|
||||
counts[m.hazard_id] = (counts[m.hazard_id] || 0) + 1
|
||||
}
|
||||
setMitCounts(counts)
|
||||
} catch { /* ignore */ }
|
||||
})()
|
||||
}, [projectId])
|
||||
|
||||
// Initialise edit state from hazard defaults
|
||||
useEffect(() => {
|
||||
const init: Record<string, EditState> = {}
|
||||
for (const h of hazards) {
|
||||
if (!edits[h.id]) {
|
||||
// Read from risk_assessment if available (enriched response), fallback to hazard fields
|
||||
const ra = (h as Record<string, unknown>).risk_assessment as Record<string, number> | null
|
||||
init[h.id] = {
|
||||
severity: ra?.severity || h.severity || 3,
|
||||
exposure: ra?.exposure || h.exposure || 3,
|
||||
probability: ra?.probability || h.probability || 3,
|
||||
avoidance: h.avoidance || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Object.keys(init).length > 0) setEdits(prev => ({ ...prev, ...init }))
|
||||
}, [hazards]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const updateEdit = useCallback((id: string, field: keyof EditState, value: number) => {
|
||||
setEdits(prev => ({ ...prev, [id]: { ...prev[id], [field]: value } }))
|
||||
}, [])
|
||||
|
||||
async function handleReassess(hazardId: string) {
|
||||
const e = edits[hazardId]
|
||||
if (!e) return
|
||||
setSaving(hazardId)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards/${hazardId}/reassess`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
hazard_id: hazardId, severity: e.severity, exposure: e.exposure,
|
||||
probability: e.probability, avoidance: e.avoidance,
|
||||
control_maturity: 3, control_coverage: 0.5, test_evidence_strength: 0.5,
|
||||
}),
|
||||
})
|
||||
if (res.ok) onReassess?.()
|
||||
} catch (err) { console.error('Reassess failed:', err) }
|
||||
finally { setSaving(null) }
|
||||
}
|
||||
|
||||
const sorted = [...hazards].sort((a, b) => (b.r_inherent || 0) - (a.r_inherent || 0))
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white">Risikobewertungstabelle (ISO 12100)</h2>
|
||||
<span className="text-xs text-gray-500">{hazards.length} Gefaehrdungen</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs whitespace-nowrap">
|
||||
<thead>
|
||||
{/* Group header */}
|
||||
<tr className="bg-gray-100 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th colSpan={2} className="px-3 py-1.5 text-left font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Gefaehrdung</th>
|
||||
<th colSpan={5} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">Erstbewertung</th>
|
||||
<th colSpan={6} className="px-3 py-1.5 text-center font-semibold text-purple-700 dark:text-purple-400 border-r border-gray-200 dark:border-gray-600">Nach Massnahmen (editierbar)</th>
|
||||
<th colSpan={2} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-600">SIL / PL</th>
|
||||
<th colSpan={2} className="px-3 py-1.5 text-center font-semibold text-gray-700 dark:text-gray-300">Status</th>
|
||||
</tr>
|
||||
{/* Column header */}
|
||||
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider">Bezeichnung</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-500 uppercase tracking-wider border-r border-gray-200 dark:border-gray-600">Kategorie</th>
|
||||
{/* Initial */}
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500">S</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500">E</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500">P</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500">RPZ</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500 border-r border-gray-200 dark:border-gray-600">Risiko</th>
|
||||
{/* After */}
|
||||
<th className="px-2 py-2 text-center font-medium text-purple-600">S</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-purple-600">E</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-purple-600">P</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-purple-600">RPZ</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-purple-600">Risiko</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-purple-600 border-r border-gray-200 dark:border-gray-600"></th>
|
||||
{/* SIL/PL */}
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500">SIL</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500 border-r border-gray-200 dark:border-gray-600">PL</th>
|
||||
{/* Status */}
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500">Massn.</th>
|
||||
<th className="px-2 py-2 text-center font-medium text-gray-500">Akzeptabel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{sorted.map(h => {
|
||||
const e = edits[h.id]
|
||||
const initRpz = h.r_inherent || rpz(h.severity, h.exposure, h.probability, h.avoidance)
|
||||
const afterRpz = e ? rpz(e.severity, e.exposure, e.probability, e.avoidance) : initRpz
|
||||
const afterLevel = getRiskLevelISO(afterRpz)
|
||||
const sil = silFromRpz(afterRpz)
|
||||
const pl = plFromRpz(afterRpz)
|
||||
const mc = mitCounts[h.id] || 0
|
||||
const changed = e && (e.severity !== h.severity || e.exposure !== h.exposure || e.probability !== h.probability || e.avoidance !== (h.avoidance || 3))
|
||||
|
||||
return (
|
||||
<tr key={h.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
{/* Hazard info */}
|
||||
<td className="px-3 py-2 max-w-[200px]">
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">{h.name}</div>
|
||||
{h.component_name && <div className="text-[10px] text-gray-400 truncate">{h.component_name}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 border-r border-gray-200 dark:border-gray-600">
|
||||
<span className="inline-block px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 text-[10px] font-medium">
|
||||
{CATEGORY_LABELS[h.category] || h.category}
|
||||
</span>
|
||||
</td>
|
||||
{/* Initial S/E/P/RPZ/Risk */}
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.severity}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.exposure}</td>
|
||||
<td className="px-2 py-2 text-center text-gray-700 dark:text-gray-300">{h.probability}</td>
|
||||
<td className="px-2 py-2 text-center font-bold text-gray-900 dark:text-white">{initRpz}</td>
|
||||
<td className="px-2 py-2 text-center border-r border-gray-200 dark:border-gray-600">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium border ${getRiskColor(h.risk_level)}`}>
|
||||
{getRiskLevelLabel(h.risk_level)}
|
||||
</span>
|
||||
</td>
|
||||
{/* After measures (editable) */}
|
||||
<td className="px-1 py-2 text-center">{e && <InlineSelect value={e.severity} onChange={v => updateEdit(h.id, 'severity', v)} label="S nach" />}</td>
|
||||
<td className="px-1 py-2 text-center">{e && <InlineSelect value={e.exposure} onChange={v => updateEdit(h.id, 'exposure', v)} label="E nach" />}</td>
|
||||
<td className="px-1 py-2 text-center">{e && <InlineSelect value={e.probability} onChange={v => updateEdit(h.id, 'probability', v)} label="P nach" />}</td>
|
||||
<td className="px-2 py-2 text-center font-bold text-purple-900 dark:text-purple-300">{afterRpz}</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded-full text-[10px] font-medium border ${getRiskColor(afterLevel)}`}>
|
||||
{getRiskLevelLabel(afterLevel)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-1 py-2 text-center border-r border-gray-200 dark:border-gray-600">
|
||||
{changed && (
|
||||
<button onClick={() => handleReassess(h.id)} disabled={saving === h.id}
|
||||
className="px-1.5 py-0.5 bg-purple-600 text-white rounded text-[10px] hover:bg-purple-700 disabled:opacity-50 transition-colors">
|
||||
{saving === h.id ? '...' : 'Speichern'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
{/* SIL / PL */}
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${SIL_COLORS[sil]}`}>
|
||||
{sil > 0 ? `SIL ${sil}` : '-'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center border-r border-gray-200 dark:border-gray-600">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-bold ${PL_COLORS[pl]}`}>
|
||||
PL {pl}
|
||||
</span>
|
||||
</td>
|
||||
{/* Status */}
|
||||
<td className="px-2 py-2 text-center">
|
||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${mc > 0 ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{mc}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-center">
|
||||
{afterRpz <= 20 ? (
|
||||
<span className="inline-block w-4 h-4 rounded-full bg-green-500 text-white text-[10px] leading-4 text-center" title="Akzeptabel">✓</span>
|
||||
) : afterRpz <= 60 ? (
|
||||
<span className="inline-block w-4 h-4 rounded-full bg-yellow-400 text-yellow-900 text-[10px] leading-4 text-center" title="Bedingt">≈</span>
|
||||
) : (
|
||||
<span className="inline-block w-4 h-4 rounded-full bg-red-500 text-white text-[10px] leading-4 text-center" title="Nicht akzeptabel">✗</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{hazards.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-sm text-gray-500">
|
||||
Keine Gefaehrdungen vorhanden. Fuegen Sie zuerst Gefaehrdungen hinzu.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -169,5 +169,6 @@ export function useHazards(projectId: string) {
|
||||
suggestingAI, matchingPatterns, matchResult, setMatchResult, applyingPatterns,
|
||||
fetchLibrary, handleAddFromLibrary, handleSubmit,
|
||||
handleAISuggestions, handlePatternMatching, handleApplyPatterns, handleDelete,
|
||||
refetch: fetchHazards,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { HazardForm } from './_components/HazardForm'
|
||||
import { HazardTable } from './_components/HazardTable'
|
||||
import { RiskAssessmentTable } from './_components/RiskAssessmentTable'
|
||||
import { LibraryModal } from './_components/LibraryModal'
|
||||
import { AutoSuggestPanel } from './_components/AutoSuggestPanel'
|
||||
import { useHazards } from './_hooks/useHazards'
|
||||
|
||||
type ViewMode = 'list' | 'risk'
|
||||
|
||||
export default function HazardsPage() {
|
||||
const params = useParams()
|
||||
const projectId = params.projectId as string
|
||||
const h = useHazards(projectId)
|
||||
const [view, setView] = useState<ViewMode>('risk')
|
||||
|
||||
if (h.loading) {
|
||||
return (
|
||||
@@ -29,9 +33,20 @@ export default function HazardsPage() {
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Gefaehrdungsanalyse mit 4-Faktor-Risikobewertung (S x F x P x A).
|
||||
</p>
|
||||
<div className="mt-2 flex rounded-lg border border-gray-200 dark:border-gray-600 overflow-hidden text-xs">
|
||||
<button onClick={() => setView('list')}
|
||||
className={`px-3 py-1.5 font-medium transition-colors ${view === 'list' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||
Hazard-Liste
|
||||
</button>
|
||||
<button onClick={() => setView('risk')}
|
||||
className={`px-3 py-1.5 font-medium transition-colors border-l border-gray-200 dark:border-gray-600 ${view === 'risk' ? 'bg-purple-600 text-white' : 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-50'}`}>
|
||||
Risikobewertung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={h.handlePatternMatching} disabled={h.matchingPatterns}
|
||||
title="Erkennt automatisch Gefaehrdungen anhand der Komponenten-Tags und Lebensphasen"
|
||||
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors disabled:opacity-50 text-sm">
|
||||
{h.matchingPatterns ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600" />
|
||||
@@ -40,18 +55,7 @@ export default function HazardsPage() {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
)}
|
||||
Auto-Erkennung
|
||||
</button>
|
||||
<button onClick={h.handleAISuggestions} disabled={h.suggestingAI}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors disabled:opacity-50 text-sm">
|
||||
{h.suggestingAI ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-purple-600" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)}
|
||||
KI-Vorschlaege
|
||||
Gefaehrdungen erkennen
|
||||
</button>
|
||||
<button onClick={h.fetchLibrary}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-sm">
|
||||
@@ -70,12 +74,12 @@ export default function HazardsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{h.matchResult && h.matchResult.matched_patterns.length > 0 && (
|
||||
{h.matchResult && h.matchResult.matched_patterns?.length > 0 && (
|
||||
<AutoSuggestPanel projectId={projectId} matchResult={h.matchResult} applying={h.applyingPatterns}
|
||||
onApply={h.handleApplyPatterns} onClose={() => h.setMatchResult(null)} />
|
||||
)}
|
||||
|
||||
{h.matchResult && h.matchResult.matched_patterns.length === 0 && (
|
||||
{h.matchResult && (!h.matchResult.matched_patterns || h.matchResult.matched_patterns.length === 0) && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-xl p-4 flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-yellow-600 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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" />
|
||||
@@ -121,7 +125,11 @@ export default function HazardsPage() {
|
||||
)}
|
||||
|
||||
{h.hazards.length > 0 ? (
|
||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||
view === 'risk' ? (
|
||||
<RiskAssessmentTable projectId={projectId} hazards={h.hazards} onReassess={h.refetch} />
|
||||
) : (
|
||||
<HazardTable hazards={h.hazards} lifecyclePhases={h.lifecyclePhases} onDelete={h.handleDelete} />
|
||||
)
|
||||
) : (
|
||||
!h.showForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// IACE Interview Types — structured questions based on CE risk assessment document structure
|
||||
|
||||
export interface InterviewQuestion {
|
||||
id: string
|
||||
section: number
|
||||
sectionTitle: string
|
||||
question: string
|
||||
type: 'text' | 'textarea' | 'select' | 'multiselect' | 'number'
|
||||
options?: string[]
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export interface InterviewAnswer {
|
||||
questionId: string
|
||||
value: string | string[] | number
|
||||
}
|
||||
|
||||
export const INTERVIEW_QUESTIONS: InterviewQuestion[] = [
|
||||
// Section 1: Maschinenbeschreibung
|
||||
{ id: 'machine_name', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Wie heisst die Maschine / Anlage?', type: 'text', placeholder: 'z.B. Kniehebelpresse HP-500', required: true },
|
||||
{ id: 'machine_type', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Welcher Maschinentyp ist es?', type: 'select', options: ['Presse', 'Roboter', 'CNC-Maschine', 'Foerderanlage', 'Verpackungsmaschine', 'Schweissanlage', 'Montageanlage', 'Sondermaschine'], required: true },
|
||||
{ id: 'manufacturer', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Wer ist der Hersteller?', type: 'text', placeholder: 'z.B. Mueller Maschinenbau GmbH' },
|
||||
{ id: 'description', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Beschreiben Sie die Anlage und ihre Funktion:', type: 'textarea', placeholder: 'Die Anlage ist eine vollautomatische...', helpText: 'Beschreiben Sie den Zweck, die Arbeitsweise und den Aufbau der Maschine.', required: true },
|
||||
{ id: 'components', section: 1, sectionTitle: 'Maschinenbeschreibung', question: 'Aus welchen Baugruppen besteht die Anlage?', type: 'multiselect', options: ['Zufuehrung', 'Presse/Umformung', 'Transferanlage', 'Foerderband', 'Roboter', 'Absaugung', 'Schmieranlage', 'Schutzumhausung', 'Aufzug/Hubwerk', 'Schaltschrank/Steuerung', 'Kuehlung', 'Heizung', 'Hydraulik', 'Pneumatik'] },
|
||||
|
||||
// Section 2: Lebensphasen
|
||||
{ id: 'lifecycle_operation', section: 2, sectionTitle: 'Lebensphasen', question: 'Wie laeuft der Normalbetrieb ab?', type: 'textarea', placeholder: 'Die Bearbeitung erfolgt vollautomatisch...', helpText: 'Beschreiben Sie den typischen Produktionszyklus.' },
|
||||
{ id: 'lifecycle_setup', section: 2, sectionTitle: 'Lebensphasen', question: 'Welche Arbeiten fallen beim Einrichten/Umruesten an?', type: 'textarea', placeholder: 'Werkzeugwechsel, Parameteranpassung...' },
|
||||
{ id: 'lifecycle_maintenance', section: 2, sectionTitle: 'Lebensphasen', question: 'Welche Wartungs- und Reinigungsarbeiten sind noetig?', type: 'textarea', placeholder: 'Woechentliche Schmierung, Filter reinigen...' },
|
||||
|
||||
// Section 3: Bestimmungsgemäße Verwendung
|
||||
{ id: 'intended_use', section: 3, sectionTitle: 'Bestimmungsgemäße Verwendung', question: 'Wozu dient die Maschine (bestimmungsgemäße Verwendung)?', type: 'textarea', placeholder: 'Die Anlage dient der automatischen...', required: true },
|
||||
|
||||
// Section 4: Vorhersehbare Fehlanwendung
|
||||
{ id: 'misuse', section: 4, sectionTitle: 'Vorhersehbare Fehlanwendung', question: 'Welche vorhersehbaren Fehlanwendungen sind moeglich?', type: 'multiselect', options: ['Ueberschreiten von Belastungsgrenzen', 'Verwendung ungeeigneter Materialien', 'Betrieb in explosionsgefaehrdeter Atmosphaere', 'Betrieb bei Leckagen', 'Betrieb ohne PSA', 'Umgehung von Sicherheitseinrichtungen', 'Bedienung ohne Einweisung', 'Manipulation der Steuerung'], helpText: 'Waehlen Sie alle zutreffenden oder ergaenzen Sie.' },
|
||||
|
||||
// Section 5: Qualifikation
|
||||
{ id: 'operator_qualification', section: 5, sectionTitle: 'Qualifikation der Benutzer', question: 'Welche Qualifikation hat das Bedienpersonal?', type: 'select', options: ['Eingewiesenes Personal ohne Fachkenntnisse', 'Angelernte Mitarbeiter', 'Facharbeiter mit Berufsausbildung', 'Ingenieure/Techniker', 'Elektrofachkraefte'] },
|
||||
{ id: 'maintenance_qualification', section: 5, sectionTitle: 'Qualifikation der Benutzer', question: 'Wer fuehrt Wartung/Instandhaltung durch?', type: 'select', options: ['Eigenes Fachpersonal', 'Hersteller-Service', 'Fremdfirma', 'Nicht separat betrachtet (CE-Erklaerung Lieferant)'] },
|
||||
|
||||
// Section 6: Grenzen
|
||||
{ id: 'spatial_limits', section: 6, sectionTitle: 'Raeumliche und zeitliche Grenzen', question: 'Welche Gefahrenbereiche gibt es?', type: 'textarea', placeholder: 'Werkzeugeinbauraum, Zufuehrbereich, Auslaufbereich...', helpText: 'Listen Sie alle Bereiche auf, in denen Personen gefaehrdet sein koennten.' },
|
||||
{ id: 'safety_measures_org', section: 6, sectionTitle: 'Raeumliche und zeitliche Grenzen', question: 'Welche organisatorischen Schutzmassnahmen gelten?', type: 'multiselect', options: ['Sicherheitsschuhe Pflicht', 'Gehoerschutz Pflicht', 'Handschuhe Pflicht', 'Schutzbrille Pflicht', 'Zutrittsbeschraenkung', 'Unterweisung vor Zugang'] },
|
||||
|
||||
// Section 7: Technische Daten
|
||||
{ id: 'force_pressure', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Kraefte/Druecke wirken? (kN, bar, Tonnen)', type: 'text', placeholder: 'z.B. 20000 kN, 250 bar' },
|
||||
{ id: 'voltage', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Spannungen sind vorhanden? (V)', type: 'text', placeholder: 'z.B. 400V Hauptstrom, 24V Steuerung' },
|
||||
{ id: 'temperature', section: 7, sectionTitle: 'Technische Daten', question: 'Treten erhoehte Temperaturen auf? (°C)', type: 'text', placeholder: 'z.B. 130°C Werkstuecktemperatur' },
|
||||
{ id: 'speed_rpm', section: 7, sectionTitle: 'Technische Daten', question: 'Welche Geschwindigkeiten/Drehzahlen gibt es? (/min, m/s)', type: 'text', placeholder: 'z.B. 736 /min Schwungrad, 36 Huebe/min' },
|
||||
{ id: 'energy', section: 7, sectionTitle: 'Technische Daten', question: 'Welches Arbeitsvermoegen hat die Maschine? (kJ, kW)', type: 'text', placeholder: 'z.B. 400 kJ, 3 kW Motor' },
|
||||
|
||||
// Section 8: Umgebung
|
||||
{ id: 'environment', section: 8, sectionTitle: 'Umgebungsbedingungen', question: 'Unter welchen Umgebungsbedingungen wird die Maschine betrieben?', type: 'textarea', placeholder: '+5 bis +40°C, max. 95% Luftfeuchte, bis 1000m ueNN', helpText: 'Temperatur, Luftfeuchte, Hoehenlage, besondere Bedingungen.' },
|
||||
]
|
||||
|
||||
export function answersToNarrativeText(answers: InterviewAnswer[]): string {
|
||||
const parts: string[] = []
|
||||
const getVal = (id: string) => {
|
||||
const a = answers.find(a => a.questionId === id)
|
||||
if (!a) return ''
|
||||
return Array.isArray(a.value) ? (a.value as string[]).join(', ') : String(a.value)
|
||||
}
|
||||
|
||||
parts.push(`Maschinenname: ${getVal('machine_name')}. Maschinentyp: ${getVal('machine_type')}. Hersteller: ${getVal('manufacturer')}.`)
|
||||
if (getVal('description')) parts.push(getVal('description'))
|
||||
if (getVal('components')) parts.push(`Baugruppen: ${getVal('components')}.`)
|
||||
if (getVal('lifecycle_operation')) parts.push(`Betrieb: ${getVal('lifecycle_operation')}`)
|
||||
if (getVal('lifecycle_setup')) parts.push(`Einrichten: ${getVal('lifecycle_setup')}`)
|
||||
if (getVal('lifecycle_maintenance')) parts.push(`Wartung: ${getVal('lifecycle_maintenance')}`)
|
||||
if (getVal('intended_use')) parts.push(`Bestimmungsgemäße Verwendung: ${getVal('intended_use')}`)
|
||||
if (getVal('misuse')) parts.push(`Vorhersehbare Fehlanwendung: ${getVal('misuse')}`)
|
||||
if (getVal('operator_qualification')) parts.push(`Bedienpersonal: ${getVal('operator_qualification')}`)
|
||||
if (getVal('spatial_limits')) parts.push(`Gefahrenbereiche: ${getVal('spatial_limits')}`)
|
||||
if (getVal('safety_measures_org')) parts.push(`Organisatorische Massnahmen: ${getVal('safety_measures_org')}`)
|
||||
if (getVal('force_pressure')) parts.push(getVal('force_pressure'))
|
||||
if (getVal('voltage')) parts.push(getVal('voltage'))
|
||||
if (getVal('temperature')) parts.push(getVal('temperature'))
|
||||
if (getVal('speed_rpm')) parts.push(getVal('speed_rpm'))
|
||||
if (getVal('energy')) parts.push(getVal('energy'))
|
||||
if (getVal('environment')) parts.push(`Umgebung: ${getVal('environment')}`)
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { INTERVIEW_QUESTIONS, answersToNarrativeText, type InterviewAnswer, type InterviewQuestion } from './_types'
|
||||
|
||||
type InputMode = 'interview' | 'wizard' | 'form'
|
||||
|
||||
export default function IACEInterviewPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>()
|
||||
const [mode, setMode] = useState<InputMode>('interview')
|
||||
const [answers, setAnswers] = useState<InterviewAnswer[]>([])
|
||||
const [currentQ, setCurrentQ] = useState(0)
|
||||
const [currentSection, setCurrentSection] = useState(1)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [multiValue, setMultiValue] = useState<string[]>([])
|
||||
|
||||
const setAnswer = (qId: string, value: string | string[] | number) => {
|
||||
setAnswers(prev => {
|
||||
const existing = prev.findIndex(a => a.questionId === qId)
|
||||
if (existing >= 0) { prev[existing].value = value; return [...prev] }
|
||||
return [...prev, { questionId: qId, value }]
|
||||
})
|
||||
}
|
||||
|
||||
const getAnswer = (qId: string) => answers.find(a => a.questionId === qId)?.value || ''
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true)
|
||||
const narrativeText = answersToNarrativeText(answers)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/parse-narrative`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ narrative_text: narrativeText }),
|
||||
})
|
||||
if (res.ok) setResult(await res.json())
|
||||
} catch { /* ignore */ }
|
||||
setAnalyzing(false)
|
||||
}
|
||||
|
||||
const q = INTERVIEW_QUESTIONS[currentQ]
|
||||
const sections = [...new Set(INTERVIEW_QUESTIONS.map(q => q.section))]
|
||||
const sectionQuestions = (s: number) => INTERVIEW_QUESTIONS.filter(q => q.section === s)
|
||||
|
||||
// Interview mode: advance to next question
|
||||
const handleInterviewNext = () => {
|
||||
if (q.type === 'multiselect') { setAnswer(q.id, multiValue); setMultiValue([]) }
|
||||
else if (inputValue) { setAnswer(q.id, inputValue); setInputValue('') }
|
||||
if (currentQ < INTERVIEW_QUESTIONS.length - 1) setCurrentQ(currentQ + 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Mode Switcher */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900">CE-Risikobeurteilung — Datenerfassung</h1>
|
||||
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
|
||||
{([['interview', 'Interview'], ['wizard', 'Wizard'], ['form', 'Formular']] as [InputMode, string][]).map(([m, label]) => (
|
||||
<button key={m} onClick={() => setMode(m)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${mode === m ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-green-900">Analyse-Ergebnis (deterministisch)</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-purple-600">{result.components?.length || 0}</div><div className="text-xs text-gray-500">Komponenten</div></div>
|
||||
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-red-600">{result.suggested_hazards?.length || 0}</div><div className="text-xs text-gray-500">Gefahren</div></div>
|
||||
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-blue-600">{result.matched_patterns || 0}</div><div className="text-xs text-gray-500">Patterns</div></div>
|
||||
<div className="bg-white rounded-lg p-3 text-center"><div className="text-2xl font-bold text-green-600">{result.energy_sources?.length || 0}</div><div className="text-xs text-gray-500">Energiequellen</div></div>
|
||||
</div>
|
||||
{result.suggested_hazards?.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium text-gray-900 text-sm">Erkannte Gefahren:</h3>
|
||||
{result.suggested_hazards.map((h: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-3 bg-white rounded-lg p-2 border border-gray-100">
|
||||
<span className={`px-2 py-0.5 text-[10px] font-medium rounded-full ${h.priority >= 90 ? 'bg-red-100 text-red-700' : h.priority >= 70 ? 'bg-orange-100 text-orange-700' : 'bg-yellow-100 text-yellow-700'}`}>P{h.priority}</span>
|
||||
<span className="text-sm text-gray-700">{h.pattern_name || h.category}</span>
|
||||
<span className="text-xs text-gray-400 ml-auto">{h.category}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════ INTERVIEW MODE ═══════════════ */}
|
||||
{mode === 'interview' && !result && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 max-w-2xl mx-auto">
|
||||
<div className="space-y-4">
|
||||
{/* Previous answers (chat history) */}
|
||||
<div className="space-y-3 max-h-[400px] overflow-y-auto">
|
||||
{INTERVIEW_QUESTIONS.slice(0, currentQ).map((pq, i) => {
|
||||
const ans = getAnswer(pq.id)
|
||||
if (!ans || (Array.isArray(ans) && ans.length === 0)) return null
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="text-xs text-purple-600 font-medium">{pq.question}</div>
|
||||
<div className="text-sm text-gray-700 bg-gray-50 rounded-lg px-3 py-2">
|
||||
{Array.isArray(ans) ? ans.join(', ') : String(ans)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Current question */}
|
||||
{currentQ < INTERVIEW_QUESTIONS.length && (
|
||||
<div className="border-t border-gray-100 pt-4">
|
||||
<div className="text-xs text-gray-400 mb-1">Frage {currentQ + 1}/{INTERVIEW_QUESTIONS.length} — {q.sectionTitle}</div>
|
||||
<div className="text-sm font-medium text-gray-900 mb-3">{q.question}</div>
|
||||
{q.helpText && <p className="text-xs text-gray-500 mb-2">{q.helpText}</p>}
|
||||
|
||||
{q.type === 'text' && (
|
||||
<input value={inputValue} onChange={e => setInputValue(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleInterviewNext()}
|
||||
placeholder={q.placeholder} className="w-full px-4 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500" autoFocus />
|
||||
)}
|
||||
{q.type === 'textarea' && (
|
||||
<textarea value={inputValue} onChange={e => setInputValue(e.target.value)} rows={4}
|
||||
placeholder={q.placeholder} className="w-full px-4 py-2 border border-gray-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500" autoFocus />
|
||||
)}
|
||||
{q.type === 'select' && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{q.options?.map(opt => (
|
||||
<button key={opt} onClick={() => { setAnswer(q.id, opt); if (currentQ < INTERVIEW_QUESTIONS.length - 1) setCurrentQ(currentQ + 1) }}
|
||||
className="px-3 py-1.5 text-sm bg-gray-50 border border-gray-200 rounded-lg hover:bg-purple-50 hover:border-purple-300">
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{q.type === 'multiselect' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{q.options?.map(opt => (
|
||||
<label key={opt} className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg border cursor-pointer ${multiValue.includes(opt) ? 'bg-purple-50 border-purple-300' : 'bg-gray-50 border-gray-200'}`}>
|
||||
<input type="checkbox" checked={multiValue.includes(opt)} onChange={e => setMultiValue(e.target.checked ? [...multiValue, opt] : multiValue.filter(v => v !== opt))} className="w-3.5 h-3.5 text-purple-600" />
|
||||
{opt}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between mt-4">
|
||||
<button onClick={() => currentQ > 0 && setCurrentQ(currentQ - 1)} disabled={currentQ === 0}
|
||||
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 disabled:opacity-30">Zurueck</button>
|
||||
<button onClick={handleInterviewNext}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
{currentQ === INTERVIEW_QUESTIONS.length - 1 ? 'Abschliessen' : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentQ >= INTERVIEW_QUESTIONS.length && (
|
||||
<button onClick={handleAnalyze} disabled={analyzing}
|
||||
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium disabled:opacity-50">
|
||||
{analyzing ? 'Analysiere deterministisch...' : 'Risikobeurteilung starten'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════ WIZARD MODE ═══════════════ */}
|
||||
{mode === 'wizard' && !result && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{sections.map(s => (
|
||||
<button key={s} onClick={() => setCurrentSection(s)}
|
||||
className={`w-8 h-8 rounded-full text-xs font-medium ${currentSection === s ? 'bg-purple-600 text-white' : s < currentSection ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-500'}`}>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="font-semibold text-gray-900 mb-4">{INTERVIEW_QUESTIONS.find(q => q.section === currentSection)?.sectionTitle}</h2>
|
||||
<div className="space-y-4">
|
||||
{sectionQuestions(currentSection).map(q => (
|
||||
<div key={q.id}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{q.question}</label>
|
||||
{q.type === 'textarea' ? (
|
||||
<textarea value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} rows={3} placeholder={q.placeholder}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||
) : q.type === 'select' ? (
|
||||
<select value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white">
|
||||
<option value="">-- Bitte waehlen --</option>
|
||||
{q.options?.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
) : q.type === 'multiselect' ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{q.options?.map(opt => {
|
||||
const current = (getAnswer(q.id) as string[] || [])
|
||||
return (
|
||||
<label key={opt} className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded border cursor-pointer ${current.includes(opt) ? 'bg-purple-50 border-purple-300' : 'border-gray-200'}`}>
|
||||
<input type="checkbox" checked={current.includes(opt)} onChange={e => setAnswer(q.id, e.target.checked ? [...current, opt] : current.filter((v: string) => v !== opt))} className="w-3 h-3" />
|
||||
{opt}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<input value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} placeholder={q.placeholder}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-6 pt-4 border-t">
|
||||
<button onClick={() => setCurrentSection(Math.max(1, currentSection - 1))} disabled={currentSection === 1}
|
||||
className="px-4 py-2 text-sm text-gray-500 disabled:opacity-30">Zurueck</button>
|
||||
{currentSection < sections.length ? (
|
||||
<button onClick={() => setCurrentSection(currentSection + 1)} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg">Weiter</button>
|
||||
) : (
|
||||
<button onClick={handleAnalyze} disabled={analyzing} className="px-6 py-2 bg-green-600 text-white rounded-lg font-medium disabled:opacity-50">
|
||||
{analyzing ? 'Analysiere...' : 'Risikobeurteilung starten'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════ FORM MODE (Accordion) ═══════════════ */}
|
||||
{mode === 'form' && !result && (
|
||||
<div className="space-y-3">
|
||||
{sections.map(s => {
|
||||
const qs = sectionQuestions(s)
|
||||
const title = qs[0]?.sectionTitle || ''
|
||||
return (
|
||||
<details key={s} open={s === 1} className="bg-white rounded-xl border border-gray-200">
|
||||
<summary className="px-6 py-4 cursor-pointer font-medium text-gray-900 hover:bg-gray-50">{s}. {title}</summary>
|
||||
<div className="px-6 pb-4 space-y-3">
|
||||
{qs.map(q => (
|
||||
<div key={q.id}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{q.question}</label>
|
||||
{q.type === 'textarea' ? (
|
||||
<textarea value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} rows={3} placeholder={q.placeholder}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||
) : q.type === 'select' ? (
|
||||
<select value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm bg-white">
|
||||
<option value="">-- Bitte waehlen --</option>
|
||||
{q.options?.map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
) : q.type === 'multiselect' ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{q.options?.map(opt => {
|
||||
const current = (getAnswer(q.id) as string[] || [])
|
||||
return (
|
||||
<label key={opt} className={`flex items-center gap-1.5 px-2 py-1 text-xs rounded border cursor-pointer ${current.includes(opt) ? 'bg-purple-50 border-purple-300' : 'border-gray-200'}`}>
|
||||
<input type="checkbox" checked={current.includes(opt)} onChange={e => setAnswer(q.id, e.target.checked ? [...current, opt] : current.filter((v: string) => v !== opt))} className="w-3 h-3" />
|
||||
{opt}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<input value={String(getAnswer(q.id))} onChange={e => setAnswer(q.id, e.target.value)} placeholder={q.placeholder}
|
||||
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
<button onClick={handleAnalyze} disabled={analyzing}
|
||||
className="w-full px-6 py-3 bg-green-600 text-white rounded-xl hover:bg-green-700 font-medium disabled:opacity-50 text-lg">
|
||||
{analyzing ? 'Analysiere deterministisch...' : 'Risikobeurteilung starten'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -16,8 +16,8 @@ export function MitigationCard({
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title}</h4>
|
||||
{mitigation.title.startsWith('Auto:') && (
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white">{mitigation.title || ''}</h4>
|
||||
{(mitigation.title || '').startsWith('Auto:') && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700">
|
||||
Auto
|
||||
</span>
|
||||
@@ -28,7 +28,7 @@ export function MitigationCard({
|
||||
{mitigation.description && (
|
||||
<p className="text-xs text-gray-500 mb-3">{mitigation.description}</p>
|
||||
)}
|
||||
{mitigation.linked_hazard_names.length > 0 && (
|
||||
{(mitigation.linked_hazard_names || []).length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mitigation.linked_hazard_names.map((name, i) => (
|
||||
|
||||
@@ -20,15 +20,33 @@ export function useMitigations(projectId: string) {
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||
])
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
const mits = json.mitigations || json || []
|
||||
setMitigations(mits)
|
||||
validateHierarchy(mits)
|
||||
}
|
||||
let hazardList: Hazard[] = []
|
||||
if (hazRes.ok) {
|
||||
const json = await hazRes.json()
|
||||
setHazards((json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category })))
|
||||
hazardList = (json.hazards || json || []).map((h: Hazard) => ({ id: h.id, name: h.name, risk_level: h.risk_level, category: h.category }))
|
||||
setHazards(hazardList)
|
||||
}
|
||||
if (mitRes.ok) {
|
||||
const json = await mitRes.json()
|
||||
const raw = json.mitigations || json || []
|
||||
// Map API fields (name, hazard_id) to frontend fields (title, linked_hazard_ids/names)
|
||||
const hazardMap = Object.fromEntries(hazardList.map((h) => [h.id, h.name]))
|
||||
const mits: Mitigation[] = raw.map((m: Record<string, unknown>) => ({
|
||||
id: m.id as string,
|
||||
title: (m.title || m.name || '') as string,
|
||||
description: (m.description || '') as string,
|
||||
reduction_type: (m.reduction_type === 'protective' ? 'protection' : m.reduction_type || 'design') as Mitigation['reduction_type'],
|
||||
status: (m.status || 'planned') as Mitigation['status'],
|
||||
linked_hazard_ids: m.linked_hazard_ids ? (m.linked_hazard_ids as string[]) : m.hazard_id ? [m.hazard_id as string] : [],
|
||||
linked_hazard_names: m.linked_hazard_ids
|
||||
? (m.linked_hazard_ids as string[]).map((id) => hazardMap[id] || id)
|
||||
: m.hazard_id ? [hazardMap[m.hazard_id as string] || (m.hazard_id as string)] : [],
|
||||
created_at: (m.created_at || '') as string,
|
||||
verified_at: (m.verified_at || null) as string | null,
|
||||
verified_by: (m.verified_by || null) as string | null,
|
||||
}))
|
||||
setMitigations(mits)
|
||||
validateHierarchy(mits)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
@@ -128,7 +146,7 @@ export function useMitigations(projectId: string) {
|
||||
|
||||
const byType = {
|
||||
design: mitigations.filter((m) => m.reduction_type === 'design'),
|
||||
protection: mitigations.filter((m) => m.reduction_type === 'protection'),
|
||||
protection: mitigations.filter((m) => m.reduction_type === 'protection' || m.reduction_type === 'protective'),
|
||||
information: mitigations.filter((m) => m.reduction_type === 'information'),
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { REDUCTION_TYPES } from './_components/types'
|
||||
import { REDUCTION_TYPES, Mitigation } from './_components/types'
|
||||
import { HierarchyWarning } from './_components/HierarchyWarning'
|
||||
import { MeasuresLibraryModal } from './_components/MeasuresLibraryModal'
|
||||
import { SuggestMeasuresModal } from './_components/SuggestMeasuresModal'
|
||||
import { MitigationForm } from './_components/MitigationForm'
|
||||
import { MitigationCard } from './_components/MitigationCard'
|
||||
import { StatusBadge } from './_components/StatusBadge'
|
||||
import { ProtectiveMeasure } from './_components/types'
|
||||
import { useMitigations } from './_hooks/useMitigations'
|
||||
|
||||
@@ -26,6 +26,47 @@ export default function MitigationsPage() {
|
||||
const [showLibrary, setShowLibrary] = useState(false)
|
||||
const [libraryFilter, setLibraryFilter] = useState<string | undefined>()
|
||||
const [showSuggest, setShowSuggest] = useState(false)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({ design: true, protection: true, information: true })
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchAction, setBatchAction] = useState<'verify' | 'delete' | null>(null)
|
||||
|
||||
function toggleSection(type: string) {
|
||||
setExpanded((prev) => ({ ...prev, [type]: !prev[type] }))
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function selectAllInType(type: string) {
|
||||
const items = byType[type as keyof typeof byType]
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
const allSelected = items.every((m) => next.has(m.id))
|
||||
if (allSelected) { items.forEach((m) => next.delete(m.id)) }
|
||||
else { items.forEach((m) => next.add(m.id)) }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleBatchVerify() {
|
||||
setBatchAction('verify')
|
||||
for (const id of selected) { await handleVerify(id) }
|
||||
setSelected(new Set())
|
||||
setBatchAction(null)
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!confirm(`${selected.size} Massnahmen wirklich loeschen?`)) return
|
||||
setBatchAction('delete')
|
||||
for (const id of selected) { await handleDelete(id) }
|
||||
setSelected(new Set())
|
||||
setBatchAction(null)
|
||||
}
|
||||
|
||||
function handleOpenLibrary(type?: string) {
|
||||
setLibraryFilter(type)
|
||||
@@ -39,11 +80,6 @@ export default function MitigationsPage() {
|
||||
setPreselectedType(measure.reduction_type as 'design' | 'protection' | 'information')
|
||||
}
|
||||
|
||||
function handleAddForType(type: 'design' | 'protection' | 'information') {
|
||||
setPreselectedType(type)
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -52,42 +88,51 @@ export default function MitigationsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const totalMeasures = byType.design.length + byType.protection.length + byType.information.length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Massnahmen</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Risikominderung nach dem 3-Stufen-Verfahren: Design → Schutz → Information.
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{totalMeasures} Massnahmen nach 3-Stufen-Verfahren: Design ({byType.design.length}) → Schutz ({byType.protection.length}) → Information ({byType.information.length})
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{m.hazards.length > 0 && (
|
||||
<button onClick={() => m.setShowSuggest(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
Vorschlaege
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">{selected.size} ausgewaehlt</span>
|
||||
<button onClick={handleBatchVerify} disabled={batchAction !== null}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{batchAction === 'verify' ? 'Verifiziere...' : 'Verifizieren'}
|
||||
</button>
|
||||
<button onClick={handleBatchDelete} disabled={batchAction !== null}
|
||||
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
|
||||
Loeschen
|
||||
</button>
|
||||
<button onClick={() => setSelected(new Set())} className="px-2 py-1.5 text-xs text-gray-500 hover:text-gray-700">
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selected.size === 0 && (
|
||||
<>
|
||||
<button onClick={() => setShowSuggest(true)}
|
||||
className="px-3 py-1.5 text-xs border border-green-300 text-green-700 rounded-lg hover:bg-green-50">
|
||||
Vorschlaege
|
||||
</button>
|
||||
<button onClick={() => handleOpenLibrary()}
|
||||
className="px-3 py-1.5 text-xs border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50">
|
||||
Bibliothek
|
||||
</button>
|
||||
<button onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
|
||||
className="px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => m.handleOpenLibrary()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-50 transition-colors">
|
||||
<svg className="w-4 h-4" 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>
|
||||
Bibliothek
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setPreselectedType(undefined); setShowForm(true) }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Massnahme hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,76 +140,80 @@ export default function MitigationsPage() {
|
||||
|
||||
{showForm && (
|
||||
<MitigationForm
|
||||
onSubmit={async (data) => {
|
||||
const ok = await handleSubmit(data)
|
||||
if (ok) { setShowForm(false); setPreselectedType(undefined) }
|
||||
}}
|
||||
onCancel={() => { setShowForm(false); setPreselectedType(undefined) }}
|
||||
hazards={hazards}
|
||||
preselectedType={preselectedType}
|
||||
onOpenLibrary={handleOpenLibrary}
|
||||
onSubmit={async (data) => { const ok = await handleSubmit(data); if (ok) setShowForm(false) }}
|
||||
onCancel={() => setShowForm(false)} hazards={hazards} preselectedType={preselectedType} onOpenLibrary={handleOpenLibrary}
|
||||
/>
|
||||
)}
|
||||
{showLibrary && <MeasuresLibraryModal measures={measures} onSelect={handleSelectMeasure} onClose={() => setShowLibrary(false)} filterType={libraryFilter} />}
|
||||
{showSuggest && <SuggestMeasuresModal hazards={hazards} projectId={projectId} onAddMeasure={handleAddSuggestedMeasure} onClose={() => setShowSuggest(false)} />}
|
||||
|
||||
{showLibrary && (
|
||||
<MeasuresLibraryModal
|
||||
measures={m.measures} onSelect={m.handleSelectMeasure}
|
||||
onClose={() => m.setShowLibrary(false)} filterType={m.libraryFilter}
|
||||
/>
|
||||
)}
|
||||
{/* 3-Step Accordions */}
|
||||
{(['design', 'protection', 'information'] as const).map((type) => {
|
||||
const config = REDUCTION_TYPES[type]
|
||||
const items = byType[type]
|
||||
const isExpanded = expanded[type]
|
||||
const allSelected = items.length > 0 && items.every((m) => selected.has(m.id))
|
||||
|
||||
{showSuggest && (
|
||||
<SuggestMeasuresModal
|
||||
hazards={m.hazards} projectId={projectId}
|
||||
onAddMeasure={m.handleAddSuggestedMeasure}
|
||||
onClose={() => m.setShowSuggest(false)}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<div key={type} className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Accordion Header */}
|
||||
<button onClick={() => toggleSection(type)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ${config.headerColor}`}>
|
||||
<svg className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
{config.icon}
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-semibold">{config.label}</span>
|
||||
<span className="ml-2 text-xs opacity-75">{config.description}</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold">{items.length}</span>
|
||||
</button>
|
||||
|
||||
{/* 3-Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{(['design', 'protection', 'information'] as const).map((type) => {
|
||||
const config = REDUCTION_TYPES[type]
|
||||
const items = m.byType[type]
|
||||
return (
|
||||
<div key={type} className={`rounded-xl border ${config.color} p-4`}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg ${config.headerColor} mb-3`}>
|
||||
{config.icon}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">{config.label}</h3>
|
||||
<p className="text-xs opacity-75">{config.description}</p>
|
||||
{/* Accordion Content — Table rows */}
|
||||
{isExpanded && items.length > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-700">
|
||||
{/* Table header */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-gray-50 dark:bg-gray-750 text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<div className="w-6">
|
||||
<input type="checkbox" checked={allSelected} onChange={() => selectAllInType(type)}
|
||||
className="accent-purple-600" title="Alle auswaehlen" />
|
||||
</div>
|
||||
<div className="flex-1">Massnahme</div>
|
||||
<div className="w-24">Status</div>
|
||||
<div className="w-32">Gefaehrdung</div>
|
||||
</div>
|
||||
<span className="ml-auto text-sm font-bold">{items.length}</span>
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap gap-1">
|
||||
{config.subTypes.map((st) => (
|
||||
<span key={st.value} className="text-xs px-1.5 py-0.5 rounded bg-white/60 text-gray-500 border border-gray-200/50">
|
||||
{st.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* Rows */}
|
||||
{items.map((m) => (
|
||||
<MitigationCard key={m.id} mitigation={m} onVerify={handleVerify} onDelete={handleDelete} />
|
||||
<div key={m.id}
|
||||
className={`flex items-center gap-2 px-4 py-2 border-t border-gray-50 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors ${selected.has(m.id) ? 'bg-purple-50 dark:bg-purple-900/10' : ''}`}>
|
||||
<div className="w-6">
|
||||
<input type="checkbox" checked={selected.has(m.id)} onChange={() => toggleSelect(m.id)}
|
||||
className="accent-purple-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-900 dark:text-white truncate">{m.title || ''}</div>
|
||||
{m.description && <div className="text-xs text-gray-400 truncate">{m.description}</div>}
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<StatusBadge status={m.status} />
|
||||
</div>
|
||||
<div className="w-32 text-xs text-gray-500 truncate">
|
||||
{(m.linked_hazard_names || []).join(', ') || '-'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button onClick={() => m.handleAddForType(type)}
|
||||
className="flex-1 py-2 text-sm text-gray-500 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors">
|
||||
+ Hinzufuegen
|
||||
</button>
|
||||
<button onClick={() => m.handleOpenLibrary(type)}
|
||||
className="py-2 px-3 text-sm text-gray-400 hover:text-purple-600 hover:bg-white rounded-lg border border-dashed border-gray-300 hover:border-purple-300 transition-colors"
|
||||
title="Aus Bibliothek waehlen">
|
||||
<svg className="w-4 h-4" 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>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isExpanded && items.length === 0 && (
|
||||
<div className="px-4 py-6 text-center text-sm text-gray-400 border-t border-gray-100">
|
||||
Keine Massnahmen in dieser Stufe
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { SuggestedNorms } from './_components/SuggestedNorms'
|
||||
|
||||
interface ProjectOverview {
|
||||
id: string
|
||||
@@ -14,12 +15,12 @@ interface ProjectOverview {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
gates: Gate[]
|
||||
risk_summary: {
|
||||
critical: number
|
||||
high: number
|
||||
medium: number
|
||||
low: number
|
||||
total: number
|
||||
risk_summary?: {
|
||||
critical?: number
|
||||
high?: number
|
||||
medium?: number
|
||||
low?: number
|
||||
total?: number
|
||||
}
|
||||
component_count: number
|
||||
hazard_count: number
|
||||
@@ -120,11 +121,72 @@ export default function ProjectOverviewPage() {
|
||||
|
||||
async function fetchProject() {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}`)
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setProject(json)
|
||||
// Fetch project detail + live risk summary + mitigations count in parallel
|
||||
const [projRes, riskRes, mitRes, hazRes] = await Promise.all([
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/risk-summary`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||
])
|
||||
|
||||
if (!projRes.ok) return
|
||||
const json = await projRes.json()
|
||||
|
||||
// Live risk summary from dedicated endpoint
|
||||
let rs = json.risk_summary || {}
|
||||
if (riskRes.ok) {
|
||||
const riskJson = await riskRes.json()
|
||||
const live = riskJson.risk_summary || riskJson || {}
|
||||
rs = {
|
||||
critical: live.critical || 0,
|
||||
high: live.high || 0,
|
||||
medium: live.medium || 0,
|
||||
low: live.low || 0,
|
||||
negligible: live.negligible || 0,
|
||||
total: live.total_hazards || live.total || 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Live counts
|
||||
let mitCount = 0
|
||||
if (mitRes.ok) {
|
||||
const mitJson = await mitRes.json()
|
||||
mitCount = mitJson.total || (mitJson.mitigations || []).length || 0
|
||||
}
|
||||
let hazCount = 0
|
||||
if (hazRes.ok) {
|
||||
const hazJson = await hazRes.json()
|
||||
hazCount = hazJson.total || (hazJson.hazards || []).length || 0
|
||||
}
|
||||
|
||||
// Calculate dynamic completeness percentage
|
||||
const compCount = json.components?.length || 0
|
||||
const gates = (json.completeness_gates || json.gates || [])
|
||||
const gatesPassed = gates.filter((g: Record<string, unknown>) => g.passed === true).length
|
||||
const gatesTotal = gates.length || 1
|
||||
const completeness = Math.round((gatesPassed / gatesTotal) * 100)
|
||||
|
||||
setProject({
|
||||
...json,
|
||||
completeness_pct: completeness,
|
||||
component_count: compCount,
|
||||
hazard_count: hazCount,
|
||||
mitigation_count: mitCount,
|
||||
risk_summary: {
|
||||
critical: rs.critical || 0,
|
||||
high: rs.high || 0,
|
||||
medium: rs.medium || 0,
|
||||
low: rs.low || 0,
|
||||
total: rs.total || hazCount,
|
||||
},
|
||||
gates: gates.map((g: Record<string, unknown>) => ({
|
||||
id: g.id,
|
||||
name: g.name || g.label || '',
|
||||
description: g.description || g.details || '',
|
||||
passed: g.passed,
|
||||
required: g.required,
|
||||
})),
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch project:', err)
|
||||
} finally {
|
||||
@@ -229,15 +291,31 @@ export default function ProjectOverviewPage() {
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Risk Summary */}
|
||||
{/* Risk Summary — live from /risk-summary endpoint */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Risikozusammenfassung</h2>
|
||||
<div className="flex items-center justify-around">
|
||||
<RiskGauge label="Kritisch" value={project.risk_summary.critical} max={project.risk_summary.total || 1} color="#EF4444" />
|
||||
<RiskGauge label="Hoch" value={project.risk_summary.high} max={project.risk_summary.total || 1} color="#F97316" />
|
||||
<RiskGauge label="Mittel" value={project.risk_summary.medium} max={project.risk_summary.total || 1} color="#EAB308" />
|
||||
<RiskGauge label="Niedrig" value={project.risk_summary.low} max={project.risk_summary.total || 1} color="#22C55E" />
|
||||
{/* Risk level bars */}
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Kritisch', value: project.risk_summary?.critical || 0, color: 'bg-red-500', text: 'text-red-700' },
|
||||
{ label: 'Hoch', value: project.risk_summary?.high || 0, color: 'bg-orange-500', text: 'text-orange-700' },
|
||||
{ label: 'Mittel', value: project.risk_summary?.medium || 0, color: 'bg-yellow-500', text: 'text-yellow-700' },
|
||||
{ label: 'Niedrig', value: project.risk_summary?.low || 0, color: 'bg-green-500', text: 'text-green-700' },
|
||||
].map((level) => {
|
||||
const total = project.risk_summary?.total || 1
|
||||
const pct = Math.round((level.value / total) * 100)
|
||||
return (
|
||||
<div key={level.label} className="flex items-center gap-3">
|
||||
<span className={`text-xs font-medium w-16 ${level.text}`}>{level.label}</span>
|
||||
<div className="flex-1 bg-gray-100 rounded-full h-4 overflow-hidden">
|
||||
<div className={`${level.color} h-4 rounded-full transition-all`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-gray-900 dark:text-white w-8 text-right">{level.value}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Counts */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-white">{project.component_count}</div>
|
||||
@@ -252,6 +330,10 @@ export default function ProjectOverviewPage() {
|
||||
<div className="text-xs text-gray-500">Massnahmen</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* RPZ threshold info */}
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500">
|
||||
RPZ-Schwellen: Kritisch >100 | Hoch 60-100 | Mittel 20-60 | Niedrig <20
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completeness Gates */}
|
||||
@@ -267,6 +349,9 @@ export default function ProjectOverviewPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggested Norms */}
|
||||
<SuggestedNorms projectId={projectId} />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Schnellzugriff</h2>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface NormStats {
|
||||
total: number
|
||||
byType: Record<string, number>
|
||||
categories: string[]
|
||||
}
|
||||
|
||||
const TYPE_INFO: Record<string, { label: string; color: string }> = {
|
||||
A: { label: 'A-Normen (Grundnormen)', color: 'bg-red-50 text-red-800 border-red-200' },
|
||||
B1: { label: 'B1-Normen (Sicherheitsgrundnormen)', color: 'bg-blue-50 text-blue-800 border-blue-200' },
|
||||
B2: { label: 'B2-Normen (Sicherheitsfachgrundnormen)', color: 'bg-green-50 text-green-800 border-green-200' },
|
||||
C: { label: 'C-Normen (Maschinenspezifisch)', color: 'bg-purple-50 text-purple-800 border-purple-200' },
|
||||
}
|
||||
|
||||
const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
A: 'ISO 12100 (Grundnorm Risikobeurteilung)',
|
||||
B1: 'ISO 13849-1/2, IEC 62061, IEC 61508 (SIL/PL, Funktionale Sicherheit)',
|
||||
B2: 'Elektrik, Ergonomie, Vibration, Laerm, Brandschutz, Hydraulik/Pneumatik, Software-Safety, Emissionen, Schutzeinrichtungen, Zugaenge, Signale',
|
||||
C: '',
|
||||
}
|
||||
|
||||
export function NormsCoverage() {
|
||||
const [stats, setStats] = useState<NormStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/sdk/v1/iace/norms-library')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((json) => {
|
||||
if (!json?.norms) return
|
||||
const norms = json.norms as Array<{ norm_type: string; machine_types?: string[] }>
|
||||
const byType: Record<string, number> = {}
|
||||
const machineTypes = new Set<string>()
|
||||
for (const n of norms) {
|
||||
byType[n.norm_type] = (byType[n.norm_type] || 0) + 1
|
||||
if (n.machine_types) {
|
||||
for (const mt of n.machine_types) machineTypes.add(mt)
|
||||
}
|
||||
}
|
||||
// Group machine types into readable categories
|
||||
const catMap: Record<string, string> = {
|
||||
press: 'Pressen', hydraulic_press: 'Pressen', mechanical_press: 'Pressen', press_brake: 'Pressen',
|
||||
robot: 'Roboter', industrial_robot: 'Roboter', robot_cell: 'Roboter',
|
||||
collaborative_robot: 'Kollaborierende Roboter', cobot: 'Kollaborierende Roboter',
|
||||
woodworking: 'Holzbearbeitung', saw: 'Holzbearbeitung', circular_saw: 'Holzbearbeitung',
|
||||
panel_saw: 'Holzbearbeitung', table_saw: 'Holzbearbeitung', miter_saw: 'Holzbearbeitung',
|
||||
log_saw: 'Holzbearbeitung', planer: 'Holzbearbeitung', router: 'Holzbearbeitung',
|
||||
lathe: 'Metallbearbeitung', turning_machine: 'Metallbearbeitung', large_lathe: 'Metallbearbeitung',
|
||||
small_lathe: 'Metallbearbeitung', milling_machine: 'Metallbearbeitung', drilling_machine: 'Metallbearbeitung',
|
||||
grinding_machine: 'Metallbearbeitung', metal_saw: 'Metallbearbeitung', band_saw: 'Metallbearbeitung',
|
||||
cold_saw: 'Metallbearbeitung', shearing_machine: 'Metallbearbeitung', bending_machine: 'Metallbearbeitung',
|
||||
cnc: 'Metallbearbeitung', machining_center: 'Metallbearbeitung', transfer_machine: 'Metallbearbeitung',
|
||||
injection_molding: 'Kunststoff/Gummi', plastics_machine: 'Kunststoff/Gummi',
|
||||
compression_molding: 'Kunststoff/Gummi', blow_molding: 'Kunststoff/Gummi',
|
||||
extruder: 'Kunststoff/Gummi', plastics_press: 'Kunststoff/Gummi',
|
||||
rubber_machine: 'Kunststoff/Gummi', two_roll_mill: 'Kunststoff/Gummi',
|
||||
reaction_molding: 'Kunststoff/Gummi', calender: 'Kunststoff/Gummi',
|
||||
food_machine: 'Lebensmittel', meat_grinder: 'Lebensmittel', bread_slicer: 'Lebensmittel',
|
||||
bakery: 'Lebensmittel', mixer: 'Lebensmittel', cooker: 'Lebensmittel',
|
||||
cutter: 'Lebensmittel', food_cutter: 'Lebensmittel', filling_machine: 'Lebensmittel',
|
||||
packaging_machine: 'Verpackung', palletizer: 'Verpackung', pallet_wrapper: 'Verpackung',
|
||||
wrapping_machine: 'Verpackung', strapping_machine: 'Verpackung',
|
||||
textile_machine: 'Textilmaschinen', spinning_machine: 'Textilmaschinen',
|
||||
weaving_machine: 'Textilmaschinen', dyeing_machine: 'Textilmaschinen',
|
||||
nonwoven_machine: 'Textilmaschinen',
|
||||
agricultural_machine: 'Landmaschinen', combine_harvester: 'Landmaschinen',
|
||||
mower: 'Landmaschinen', baler: 'Landmaschinen', sprayer: 'Landmaschinen', tiller: 'Landmaschinen',
|
||||
crane: 'Krane/Hebezeuge', bridge_crane: 'Krane/Hebezeuge', gantry_crane: 'Krane/Hebezeuge',
|
||||
tower_crane: 'Krane/Hebezeuge', mobile_crane: 'Krane/Hebezeuge', hoist: 'Krane/Hebezeuge',
|
||||
winch: 'Krane/Hebezeuge', slewing_crane: 'Krane/Hebezeuge',
|
||||
elevator: 'Aufzuege', lift: 'Aufzuege', construction_hoist: 'Aufzuege',
|
||||
conveyor: 'Foerdertechnik', belt_conveyor: 'Foerdertechnik', screw_conveyor: 'Foerdertechnik',
|
||||
transfer_system: 'Foerdertechnik', rotary_transfer_machine: 'Foerdertechnik',
|
||||
forklift: 'Flurfoerderzeuge', industrial_truck: 'Flurfoerderzeuge',
|
||||
earth_moving: 'Erdbaumaschinen', excavator: 'Erdbaumaschinen',
|
||||
wheel_loader: 'Erdbaumaschinen', bulldozer: 'Erdbaumaschinen',
|
||||
welding_machine: 'Schweissmaschinen', arc_welder: 'Schweissmaschinen',
|
||||
printing_press: 'Druckmaschinen', coating_machine: 'Druckmaschinen',
|
||||
pump: 'Pumpen/Kompressoren', compressor: 'Pumpen/Kompressoren', vacuum_pump: 'Pumpen/Kompressoren',
|
||||
foundry_machine: 'Giesserei', casting_machine: 'Giesserei', die_casting: 'Giesserei',
|
||||
industrial_furnace: 'Industrieoefen', heat_treatment: 'Industrieoefen',
|
||||
dryer: 'Trockner/Oefen', oven: 'Trockner/Oefen', kiln: 'Trockner/Oefen',
|
||||
paper_machine: 'Papiermaschinen', slitter_rewinder: 'Papiermaschinen', pulper: 'Papiermaschinen',
|
||||
centrifuge: 'Zentrifugen',
|
||||
aerial_platform: 'Hubarbeitsbuehnen', cherry_picker: 'Hubarbeitsbuehnen',
|
||||
scissor_lift: 'Hubtische', lift_table: 'Hubtische',
|
||||
powered_gate: 'Tore/Tueren', industrial_door: 'Tore/Tueren',
|
||||
laser_machine: 'Lasermaschinen', laser_cutter: 'Lasermaschinen',
|
||||
silo: 'Schuettgutanlagen', bunker: 'Schuettgutanlagen',
|
||||
suspended_platform: 'Haengebuehnen', scaffold: 'Haengebuehnen',
|
||||
storage_retrieval: 'Lagertechnik', automated_warehouse: 'Lagertechnik',
|
||||
pressure_vessel: 'Druckbehaelter', hydraulic_accumulator: 'Druckbehaelter',
|
||||
}
|
||||
const cats = new Set<string>()
|
||||
for (const mt of machineTypes) {
|
||||
cats.add(catMap[mt] || mt)
|
||||
}
|
||||
const sortedCats = Array.from(cats).sort()
|
||||
setStats({ total: norms.length, byType, categories: sortedCats })
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading || !stats) return null
|
||||
|
||||
const cDesc = stats.categories.join(', ')
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-purple-50 dark:bg-purple-900/10 border border-purple-200 dark:border-purple-800 rounded-lg">
|
||||
<button onClick={() => setExpanded(!expanded)} className="w-full text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<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="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>
|
||||
<span className="text-sm font-semibold text-purple-800 dark:text-purple-300">
|
||||
Normen-Bibliothek: {stats.total} Normen in {stats.categories.length} Branchen
|
||||
</span>
|
||||
</div>
|
||||
<svg className={`w-4 h-4 text-purple-400 transition-transform ${expanded ? '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>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-purple-200 dark:border-purple-700">
|
||||
<th className="text-left py-1.5 px-2 font-semibold text-purple-800 dark:text-purple-300 w-48">Typ</th>
|
||||
<th className="text-center py-1.5 px-2 font-semibold text-purple-800 dark:text-purple-300 w-16">Anzahl</th>
|
||||
<th className="text-left py-1.5 px-2 font-semibold text-purple-800 dark:text-purple-300">Abdeckung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(['A', 'B1', 'B2', 'C'] as const).map((type) => {
|
||||
const info = TYPE_INFO[type]
|
||||
const count = stats.byType[type] || 0
|
||||
const desc = type === 'C' ? cDesc : CATEGORY_DESCRIPTIONS[type]
|
||||
return (
|
||||
<tr key={type} className="border-b border-purple-100 dark:border-purple-800/50">
|
||||
<td className="py-2 px-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded border text-xs font-medium ${info.color}`}>
|
||||
{info.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-center font-bold text-purple-900 dark:text-purple-200">{count}</td>
|
||||
<td className="py-2 px-2 text-gray-700 dark:text-gray-300">{desc}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="pt-2 text-xs text-purple-600 dark:text-purple-400">
|
||||
Alle Normen mit Abschnittsnummern und{' '}
|
||||
<a href="https://www.beuth.de" target="_blank" rel="noopener noreferrer" className="underline font-medium hover:text-purple-800">
|
||||
Beuth-Kauflinks
|
||||
</a>{' '}
|
||||
hinterlegt. Die vollstaendige Bibliothek ist unter "Normenrecherche" in jedem Projekt einsehbar.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { NormsCoverage } from './NormsCoverage'
|
||||
|
||||
type ScopeStatus = 'in_scope' | 'partially' | 'not_in_scope' | 'planned'
|
||||
|
||||
interface ProcessStep {
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
actor: string
|
||||
scope: ScopeStatus
|
||||
toolNote?: string
|
||||
}
|
||||
|
||||
const CE_PROCESS_STEPS: ProcessStep[] = [
|
||||
{
|
||||
number: 1,
|
||||
title: 'Maschinenplanung',
|
||||
description: 'Hersteller plant Maschine/Anlage',
|
||||
actor: 'Hersteller',
|
||||
scope: 'not_in_scope',
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: 'CE-Firma beauftragen',
|
||||
description: 'Hersteller beauftragt CE-Beratungsfirma oder internes CE-Team',
|
||||
actor: 'Hersteller',
|
||||
scope: 'not_in_scope',
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: 'Grenzen definieren',
|
||||
description:
|
||||
'Bestimmungsgemasse Verwendung, vorhersehbare Fehlanwendung, Betriebsarten, raeumliche/zeitliche Grenzen',
|
||||
actor: 'Gemeinsam',
|
||||
scope: 'in_scope',
|
||||
toolNote: 'Interview/Wizard tab',
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: 'Normenrecherche',
|
||||
description:
|
||||
'C-Normen (maschinenspezifisch), B-Normen (Sicherheitsfunktionen), A-Normen (ISO 12100). Harmonisierte Normen ermoeglichen Konformitaetsvermutung.',
|
||||
actor: 'CE-Firma',
|
||||
scope: 'in_scope',
|
||||
toolNote: 'manueller Eintrag',
|
||||
},
|
||||
{
|
||||
number: 5,
|
||||
title: 'Maschinenbeschreibung',
|
||||
description:
|
||||
'Komponentenbaum, Energiequellen, technische Daten, Betriebsarten systematisch erfassen',
|
||||
actor: 'CE-Firma',
|
||||
scope: 'in_scope',
|
||||
toolNote: 'Komponenten tab',
|
||||
},
|
||||
{
|
||||
number: 6,
|
||||
title: 'Gefaehrdungen identifizieren',
|
||||
description:
|
||||
'Systematisch pro Komponente x Lebenszyklus. Deterministisches Pattern-Matching generiert Vorschlaege.',
|
||||
actor: 'CE-Firma + Tool',
|
||||
scope: 'in_scope',
|
||||
toolNote: 'Hazard Log',
|
||||
},
|
||||
{
|
||||
number: 7,
|
||||
title: 'Risiko bewerten',
|
||||
description:
|
||||
'Schwere x Exposition x Eintrittswahrscheinlichkeit. Automatische SIL/PL-Ableitung aus Risikograph.',
|
||||
actor: 'CE-Firma + Tool',
|
||||
scope: 'in_scope',
|
||||
toolNote: 'Hazard Log',
|
||||
},
|
||||
{
|
||||
number: 8,
|
||||
title: 'Massnahmen definieren',
|
||||
description:
|
||||
'3-Stufen-Hierarchie (PFLICHT): 1. Design, 2. Schutzeinrichtung, 3. Information. Tool schlaegt kategorienspezifisch vor.',
|
||||
actor: 'CE-Firma + Tool',
|
||||
scope: 'in_scope',
|
||||
toolNote: 'Massnahmen tab',
|
||||
},
|
||||
{
|
||||
number: 9,
|
||||
title: 'Massnahmen umsetzen',
|
||||
description:
|
||||
'Hersteller implementiert konstruktive Aenderungen, Schutzeinrichtungen, Beschilderung etc.',
|
||||
actor: 'Hersteller',
|
||||
scope: 'partially',
|
||||
toolNote: 'Nachweis-Upload',
|
||||
},
|
||||
{
|
||||
number: 10,
|
||||
title: 'Restrisiko bewerten',
|
||||
description:
|
||||
'Iterativ: Nach Massnahmen-Umsetzung erneut bewerten. Akzeptabel? Wenn nein: zurueck zu Schritt 8.',
|
||||
actor: 'CE-Firma',
|
||||
scope: 'in_scope',
|
||||
toolNote: 'Reassessment',
|
||||
},
|
||||
{
|
||||
number: 11,
|
||||
title: 'Verifikation',
|
||||
description: 'Messungen, Berechnungen, Pruefungen. Nachweise den Massnahmen zuordnen.',
|
||||
actor: 'CE-Firma',
|
||||
scope: 'in_scope',
|
||||
toolNote: 'Verifikation tab',
|
||||
},
|
||||
{
|
||||
number: 12,
|
||||
title: 'Benannte Stelle',
|
||||
description:
|
||||
'NUR fuer Annex-IV-Maschinen (Pressen, Holzbearbeitung, Hebezeuge): Formale Baumusterpruefung durch TUeV/DGUV Test o.ae.',
|
||||
actor: 'Notified Body',
|
||||
scope: 'not_in_scope',
|
||||
},
|
||||
{
|
||||
number: 13,
|
||||
title: 'Betriebsanleitung',
|
||||
description:
|
||||
'Restrisiken fuer Bediener dokumentieren, Sicherheitshinweise, bestimmungsgemasse Verwendung',
|
||||
actor: 'CE-Firma',
|
||||
scope: 'planned',
|
||||
},
|
||||
{
|
||||
number: 14,
|
||||
title: 'Technische Unterlagen',
|
||||
description:
|
||||
'Gesamtdossier: Plaene, Schaltbilder, Berechnungen, Risikobeurteilung, Normen, Pruefberichte, Betriebsanleitung',
|
||||
actor: 'CE-Firma',
|
||||
scope: 'in_scope',
|
||||
toolNote: 'CE-Akte tab',
|
||||
},
|
||||
{
|
||||
number: 15,
|
||||
title: 'CE-Erklaerung',
|
||||
description:
|
||||
'Hersteller unterschreibt EU-Konformitaetserklaerung und bringt CE-Kennzeichnung an. Die CE-Firma gibt KEIN CE — der Hersteller traegt die Verantwortung.',
|
||||
actor: 'Hersteller',
|
||||
scope: 'not_in_scope',
|
||||
},
|
||||
]
|
||||
|
||||
const SCOPE_STYLES: Record<ScopeStatus, { border: string; bg: string; badge: string; badgeText: string }> = {
|
||||
in_scope: {
|
||||
border: 'border-l-purple-500',
|
||||
bg: 'bg-purple-50 dark:bg-purple-900/10',
|
||||
badge: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
badgeText: 'Im Tool',
|
||||
},
|
||||
partially: {
|
||||
border: 'border-l-yellow-500',
|
||||
bg: 'bg-yellow-50 dark:bg-yellow-900/10',
|
||||
badge: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
badgeText: 'Teilweise',
|
||||
},
|
||||
not_in_scope: {
|
||||
border: 'border-l-gray-300',
|
||||
bg: 'bg-gray-50 dark:bg-gray-800/50',
|
||||
badge: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
|
||||
badgeText: 'Nicht im Tool',
|
||||
},
|
||||
planned: {
|
||||
border: 'border-l-gray-300 border-dashed',
|
||||
bg: 'bg-gray-50 dark:bg-gray-800/50',
|
||||
badge: 'bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
badgeText: 'Geplant',
|
||||
},
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'iace-process-flow-collapsed'
|
||||
|
||||
function StepCard({ step }: { step: ProcessStep }) {
|
||||
const style = SCOPE_STYLES[step.scope]
|
||||
const muted = step.scope === 'not_in_scope' || step.scope === 'planned'
|
||||
|
||||
return (
|
||||
<div className={`relative flex gap-4 ${muted ? 'opacity-75' : ''}`}>
|
||||
{/* Timeline connector */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||
step.scope === 'in_scope'
|
||||
? 'bg-purple-600 text-white'
|
||||
: step.scope === 'partially'
|
||||
? 'bg-yellow-500 text-white'
|
||||
: 'bg-gray-300 text-gray-600 dark:bg-gray-600 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{step.number}
|
||||
</div>
|
||||
{step.number < 15 && (
|
||||
<div className="w-0.5 flex-1 bg-gray-200 dark:bg-gray-700 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div
|
||||
className={`flex-1 mb-3 p-4 rounded-lg border-l-4 ${style.border} ${style.bg} ${
|
||||
step.scope === 'planned' ? 'border-dashed border border-gray-300 dark:border-gray-600' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-1">
|
||||
<h4 className={`font-semibold text-sm ${muted ? 'text-gray-600 dark:text-gray-400' : 'text-gray-900 dark:text-white'}`}>
|
||||
{step.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${style.badge}`}>
|
||||
{style.badgeText}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-xs leading-relaxed ${muted ? 'text-gray-500 dark:text-gray-500' : 'text-gray-700 dark:text-gray-300'}`}>
|
||||
{step.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
{step.actor}
|
||||
</span>
|
||||
{step.toolNote && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{step.toolNote}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProcessFlow() {
|
||||
// Default to expanded (false) — avoids SSR hydration mismatch
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'true') {
|
||||
setCollapsed(true)
|
||||
}
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
function toggle() {
|
||||
const next = !collapsed
|
||||
setCollapsed(next)
|
||||
localStorage.setItem(STORAGE_KEY, String(next))
|
||||
}
|
||||
|
||||
const inScopeCount = CE_PROCESS_STEPS.filter((s) => s.scope === 'in_scope').length
|
||||
const partialCount = CE_PROCESS_STEPS.filter((s) => s.scope === 'partially').length
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Header — always visible */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="w-full flex items-center justify-between px-6 py-4 text-left hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
CE-Prozess: 15 Schritte zur Konformitaet
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{inScopeCount} Schritte im Tool abgedeckt, {partialCount} teilweise
|
||||
</p>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-5 h-5 text-gray-400 transition-transform ${collapsed ? '' : '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>
|
||||
|
||||
{/* Content — collapsible */}
|
||||
{!collapsed && (
|
||||
<div className="px-6 pb-6 border-t border-gray-100 dark:border-gray-700">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap items-center gap-4 py-3 mb-4">
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span className="w-3 h-3 rounded-sm bg-purple-500" />
|
||||
Im Tool abgedeckt
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span className="w-3 h-3 rounded-sm bg-yellow-500" />
|
||||
Teilweise abgedeckt
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span className="w-3 h-3 rounded-sm bg-gray-300 dark:bg-gray-600" />
|
||||
Nicht im Tool
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span className="w-3 h-3 rounded-sm border border-dashed border-gray-400" />
|
||||
Geplant
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="space-y-0">
|
||||
{CE_PROCESS_STEPS.map((step) => (
|
||||
<StepCard key={step.number} step={step} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Norms Coverage Table */}
|
||||
<div className="mt-4">
|
||||
<NormsCoverage />
|
||||
</div>
|
||||
|
||||
{/* Disclaimers */}
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="p-3 bg-amber-50 dark:bg-amber-900/10 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>Hinweis:</strong> Dieses Tool ersetzt NICHT die Fachkompetenz eines CE-Beraters.
|
||||
Es automatisiert die systematische Dokumentation und schlaegt Gefaehrdungen/Massnahmen vor.
|
||||
Die fachliche Bewertung und Verantwortung verbleibt beim CE-Experten und Hersteller.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<p className="text-xs font-semibold text-blue-800 dark:text-blue-300 mb-2">Normenrecherche — Rechtliche Grundlage</p>
|
||||
<div className="text-xs text-blue-700 dark:text-blue-400 leading-relaxed space-y-2">
|
||||
<div>
|
||||
<p className="font-medium mb-1">Was dieses Tool anzeigt:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-1">
|
||||
<li>Normennummern (z.B. "ISO 13857:2019") — Identifikatoren, kein geschuetzter Text</li>
|
||||
<li>Offizielle Normentitel — bibliografische Information</li>
|
||||
<li>Abschnittsnummern (z.B. "Abschnitt 4.2, Tabelle 1") — Verweisadressen</li>
|
||||
<li>Eigene Zusammenfassungen des Regelungsbereichs — unser Text, nicht Normtext</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium mb-1">Was dieses Tool NICHT anzeigt:</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 ml-1">
|
||||
<li>Normtext (auch nicht auszugsweise) — urheberrechtlich geschuetzt durch DIN/ISO/CEN</li>
|
||||
<li>Tabellenwerte oder Grenzwerte aus Normen</li>
|
||||
<li>Diagramme oder Bilder aus Normen</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-blue-600 dark:text-blue-300 pt-1">
|
||||
Normtexte muessen separat beschafft werden, z.B. ueber{' '}
|
||||
<a href="https://www.beuth.de" target="_blank" rel="noopener noreferrer" className="underline font-medium hover:text-blue-800">
|
||||
www.beuth.de
|
||||
</a>{' '}
|
||||
(DIN-Normen) oder{' '}
|
||||
<a href="https://www.iso.org" target="_blank" rel="noopener noreferrer" className="underline font-medium hover:text-blue-800">
|
||||
www.iso.org
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useParams } from 'next/navigation'
|
||||
import IACEFlowFAB from './[projectId]/_components/IACEFlowFAB'
|
||||
|
||||
const IACE_NAV_ITEMS = [
|
||||
{ id: 'overview', label: 'Uebersicht', href: '', icon: 'grid' },
|
||||
@@ -112,6 +113,15 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mt-2">
|
||||
CE-Compliance
|
||||
</h2>
|
||||
<Link
|
||||
href="/sdk/iace/lines"
|
||||
className="mt-2 flex items-center gap-1.5 text-xs text-gray-500 hover:text-purple-600 dark:text-gray-400 dark:hover:text-purple-400 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="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
Produktionslinien
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="p-2 space-y-0.5">
|
||||
{IACE_NAV_ITEMS.map((item) => (
|
||||
@@ -136,6 +146,9 @@ export default function IACELayout({ children }: { children: React.ReactNode })
|
||||
<main className="flex-1 overflow-auto bg-gray-50 dark:bg-gray-900">
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
|
||||
{/* CE Process Step Navigator FAB */}
|
||||
{projectId && <IACEFlowFAB />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { LineDashboard } from '../../_types'
|
||||
|
||||
interface AggregatePanelProps {
|
||||
dashboard: LineDashboard
|
||||
}
|
||||
|
||||
const RISK_DOTS = [
|
||||
{ key: 'critical', label: 'Kritisch', dotColor: 'bg-red-500' },
|
||||
{ key: 'high', label: 'Hoch', dotColor: 'bg-orange-500' },
|
||||
{ key: 'medium', label: 'Mittel', dotColor: 'bg-yellow-500' },
|
||||
{ key: 'low', label: 'Niedrig', dotColor: 'bg-green-500' },
|
||||
]
|
||||
|
||||
export function AggregatePanel({ dashboard }: AggregatePanelProps) {
|
||||
const { line, stations, aggregate } = dashboard
|
||||
|
||||
const totalHazards = stations.reduce((sum, s) => sum + s.hazard_count, 0)
|
||||
const totalMitigations = stations.reduce((sum, s) => sum + s.mitigation_count, 0)
|
||||
const stationCount = stations.length
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
{/* Title row */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{line.name}
|
||||
</h1>
|
||||
{line.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{line.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span>Erstellt: {new Date(line.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex flex-wrap items-center gap-6 mb-3">
|
||||
<StatPill label="Stationen" value={stationCount} />
|
||||
<StatPill label="Gefaehrdungen" value={totalHazards} />
|
||||
<StatPill label="Massnahmen" value={totalMitigations} />
|
||||
</div>
|
||||
|
||||
{/* Risk dots row */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{RISK_DOTS.map((rd) => {
|
||||
const count = aggregate[rd.key] || 0
|
||||
return (
|
||||
<span key={rd.key} className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span className={`w-2.5 h-2.5 rounded-full ${rd.dotColor}`} />
|
||||
<span className="font-semibold">{count}</span>
|
||||
<span>{rd.label}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatPill({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<span className="font-bold text-gray-900 dark:text-white">{value}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { StationIcon } from './StationIcons'
|
||||
import { STATION_TYPES } from '../../_types'
|
||||
import type { StationDashboard } from '../../_types'
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
review: 'bg-yellow-100 text-yellow-700',
|
||||
approved: 'bg-green-100 text-green-700',
|
||||
archived: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
in_progress: 'In Bearbeitung',
|
||||
review: 'In Pruefung',
|
||||
approved: 'Freigegeben',
|
||||
archived: 'Archiviert',
|
||||
}
|
||||
|
||||
const RISK_LEVELS = [
|
||||
{ key: 'critical', label: 'Kritisch', color: 'bg-red-500', text: 'text-red-700' },
|
||||
{ key: 'high', label: 'Hoch', color: 'bg-orange-500', text: 'text-orange-700' },
|
||||
{ key: 'medium', label: 'Mittel', color: 'bg-yellow-500', text: 'text-yellow-700' },
|
||||
{ key: 'low', label: 'Niedrig', color: 'bg-green-500', text: 'text-green-700' },
|
||||
]
|
||||
|
||||
interface StationCardProps {
|
||||
station: StationDashboard
|
||||
expanded: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
export function StationCard({ station, expanded, onToggle }: StationCardProps) {
|
||||
const stationType = STATION_TYPES[station.station.station_type]
|
||||
const bgColor = stationType?.bgColor || 'bg-gray-50'
|
||||
const accentColor = stationType?.color || '#6B7280'
|
||||
|
||||
const totalRisk = Object.values(station.risk_summary).reduce((a, b) => a + b, 0)
|
||||
const pctBar = station.completeness_pct
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-56 flex-shrink-0 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 overflow-hidden shadow-sm hover:shadow-md transition-shadow`}
|
||||
>
|
||||
{/* Color accent bar */}
|
||||
<div className="h-1.5" style={{ backgroundColor: accentColor }} />
|
||||
|
||||
{/* Collapsed content */}
|
||||
<div className="p-4">
|
||||
{/* Icon + name */}
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div
|
||||
className={`w-10 h-10 ${bgColor} dark:bg-opacity-20 rounded-lg flex items-center justify-center flex-shrink-0`}
|
||||
style={{ color: accentColor }}
|
||||
>
|
||||
<StationIcon type={station.station.station_type} size={22} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||
{station.station.station_label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{station.project_name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hazard count */}
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-2">
|
||||
{station.hazard_count} Gefaehrdungen
|
||||
</div>
|
||||
|
||||
{/* Completeness bar */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="h-2 rounded-full transition-all"
|
||||
style={{
|
||||
width: `${pctBar}%`,
|
||||
backgroundColor: accentColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 w-8 text-right">
|
||||
{pctBar}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* SIL / PL */}
|
||||
{(station.sil_max || station.pl_max) && (
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{station.sil_max && <span>SIL {station.sil_max}</span>}
|
||||
{station.sil_max && station.pl_max && <span>|</span>}
|
||||
{station.pl_max && <span>PL {station.pl_max}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full text-left text-xs text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 font-medium flex items-center gap-1"
|
||||
>
|
||||
{expanded ? 'Weniger anzeigen' : 'Details anzeigen'}
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
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>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 border-t border-gray-100 dark:border-gray-700 pt-3 space-y-3">
|
||||
{/* Risk breakdown */}
|
||||
<div className="space-y-1.5">
|
||||
{RISK_LEVELS.map((level) => {
|
||||
const count = station.risk_summary[level.key] || 0
|
||||
const pct = totalRisk > 0 ? Math.round((count / totalRisk) * 100) : 0
|
||||
return (
|
||||
<div key={level.key} className="flex items-center gap-2">
|
||||
<span className={`text-[10px] font-medium w-12 ${level.text}`}>{level.label}</span>
|
||||
<div className="flex-1 bg-gray-100 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
|
||||
<div className={`${level.color} h-2.5 rounded-full`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-gray-700 dark:text-gray-300 w-6 text-right">{count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Mitigation count */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500 dark:text-gray-400">Massnahmen</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-300">{station.mitigation_count}</span>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500 dark:text-gray-400">Status</span>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_COLORS[station.status] || STATUS_COLORS.draft}`}>
|
||||
{STATUS_LABELS[station.status] || station.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Link to project */}
|
||||
<Link
|
||||
href={`/sdk/iace/${station.station.project_id}`}
|
||||
className="block text-center text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 bg-purple-50 dark:bg-purple-900/20 rounded-lg py-2 transition-colors"
|
||||
>
|
||||
Zum Projekt →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from 'react'
|
||||
|
||||
interface StationIconProps {
|
||||
type: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export function StationIcon({ type, size = 24 }: StationIconProps) {
|
||||
const s = size
|
||||
const sw = 1.5
|
||||
|
||||
switch (type) {
|
||||
case 'press':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Ram pressing down */}
|
||||
<rect x="7" y="2" width="10" height="4" rx="1" />
|
||||
<line x1="12" y1="6" x2="12" y2="12" />
|
||||
<path d="M6 12h12v3H6z" />
|
||||
<line x1="12" y1="12" x2="12" y2="10" strokeWidth={2.5} />
|
||||
{/* Base block */}
|
||||
<rect x="5" y="18" width="14" height="4" rx="1" />
|
||||
{/* Workpiece */}
|
||||
<rect x="9" y="15" width="6" height="3" rx="0.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'robot':
|
||||
case 'cobot':
|
||||
case 'collaborative_robot':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Base */}
|
||||
<rect x="8" y="19" width="8" height="3" rx="1" />
|
||||
{/* Lower arm */}
|
||||
<line x1="12" y1="19" x2="8" y2="13" />
|
||||
{/* Joint */}
|
||||
<circle cx="8" cy="13" r="1.5" />
|
||||
{/* Upper arm */}
|
||||
<line x1="8" y1="13" x2="15" y2="7" />
|
||||
{/* Wrist joint */}
|
||||
<circle cx="15" cy="7" r="1.5" />
|
||||
{/* Gripper */}
|
||||
<line x1="15" y1="7" x2="18" y2="4" />
|
||||
<line x1="18" y1="4" x2="19" y2="3" />
|
||||
<line x1="18" y1="4" x2="19" y2="5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'conveyor':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Belt top */}
|
||||
<line x1="3" y1="14" x2="21" y2="14" />
|
||||
{/* Belt bottom */}
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
{/* Left roller */}
|
||||
<circle cx="5" cy="16" r="2" />
|
||||
{/* Right roller */}
|
||||
<circle cx="19" cy="16" r="2" />
|
||||
{/* Flow arrows */}
|
||||
<path d="M8 10l3-2 3 2" />
|
||||
<path d="M11 8v-2" />
|
||||
{/* Package on belt */}
|
||||
<rect x="9" y="10" width="6" height="4" rx="0.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'assembly':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Gear */}
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="12" cy="12" r="1.5" />
|
||||
{/* Gear teeth */}
|
||||
<line x1="12" y1="3" x2="12" y2="6" />
|
||||
<line x1="12" y1="18" x2="12" y2="21" />
|
||||
<line x1="3" y1="12" x2="6" y2="12" />
|
||||
<line x1="18" y1="12" x2="21" y2="12" />
|
||||
<line x1="5.6" y1="5.6" x2="7.8" y2="7.8" />
|
||||
<line x1="16.2" y1="16.2" x2="18.4" y2="18.4" />
|
||||
<line x1="5.6" y1="18.4" x2="7.8" y2="16.2" />
|
||||
<line x1="16.2" y1="7.8" x2="18.4" y2="5.6" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'milling':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Spindle */}
|
||||
<rect x="10" y="2" width="4" height="6" rx="1" />
|
||||
{/* Cutter head */}
|
||||
<circle cx="12" cy="11" r="3" />
|
||||
{/* Rotation arc */}
|
||||
<path d="M7 11a5 5 0 0 1 2.5-4.3" strokeDasharray="2 2" />
|
||||
<path d="M17 11a5 5 0 0 0-2.5-4.3" strokeDasharray="2 2" />
|
||||
{/* Workpiece / table */}
|
||||
<rect x="4" y="17" width="16" height="3" rx="1" />
|
||||
<rect x="8" y="14" width="8" height="3" rx="0.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'turning':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Chuck / rotating workpiece */}
|
||||
<circle cx="9" cy="12" r="5" />
|
||||
<circle cx="9" cy="12" r="2" />
|
||||
{/* Tool holder */}
|
||||
<line x1="16" y1="12" x2="14" y2="12" />
|
||||
<path d="M16 9v6l4-1v-4z" />
|
||||
{/* Rotation arrow */}
|
||||
<path d="M5 5a8 8 0 0 1 4 1" />
|
||||
<path d="M5 5l1.5 1.5L4.5 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'welding':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Torch body */}
|
||||
<line x1="6" y1="4" x2="12" y2="14" />
|
||||
<path d="M4 3h4l-2 3z" />
|
||||
{/* Weld point */}
|
||||
<circle cx="12" cy="16" r="1" fill="currentColor" />
|
||||
{/* Sparks */}
|
||||
<line x1="12" y1="16" x2="15" y2="13" />
|
||||
<line x1="12" y1="16" x2="16" y2="15" />
|
||||
<line x1="12" y1="16" x2="14" y2="19" />
|
||||
<line x1="12" y1="16" x2="9" y2="19" />
|
||||
{/* Workpiece */}
|
||||
<rect x="3" y="19" width="18" height="3" rx="1" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'inspection':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Magnifying glass */}
|
||||
<circle cx="10" cy="10" r="6" />
|
||||
<line x1="14.5" y1="14.5" x2="20" y2="20" strokeWidth={2} />
|
||||
{/* Checkmark inside */}
|
||||
<path d="M7.5 10l2 2 3.5-4" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'packaging':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Box */}
|
||||
<path d="M3 8l9-5 9 5v10l-9 5-9-5z" />
|
||||
<line x1="12" y1="3" x2="12" y2="23" />
|
||||
<line x1="3" y1="8" x2="12" y2="13" />
|
||||
<line x1="21" y1="8" x2="12" y2="13" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'motor':
|
||||
case 'electric_motor':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Motor body circle */}
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
{/* Lightning bolt */}
|
||||
<path d="M13 6l-3 6h4l-3 6" strokeWidth={2} />
|
||||
{/* Shaft */}
|
||||
<line x1="20" y1="12" x2="23" y2="12" strokeWidth={2} />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'rotary_transfer':
|
||||
case 'rotary_transfer_machine':
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
{/* Circular path */}
|
||||
<circle cx="12" cy="12" r="8" strokeDasharray="4 2" />
|
||||
{/* Center */}
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
{/* Station dots around circle */}
|
||||
<circle cx="12" cy="4" r="1.5" fill="currentColor" />
|
||||
<circle cx="19" cy="8" r="1.5" fill="currentColor" />
|
||||
<circle cx="19" cy="16" r="1.5" fill="currentColor" />
|
||||
<circle cx="12" cy="20" r="1.5" fill="currentColor" />
|
||||
<circle cx="5" cy="16" r="1.5" fill="currentColor" />
|
||||
<circle cx="5" cy="8" r="1.5" fill="currentColor" />
|
||||
{/* Rotation arrow */}
|
||||
<path d="M16 3.5l-1 2h2z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { TransferInfo } from '../../_types'
|
||||
|
||||
interface TransferLineProps {
|
||||
transfer: TransferInfo
|
||||
color: string
|
||||
}
|
||||
|
||||
export function TransferLine({ transfer, color }: TransferLineProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-20 flex-shrink-0 py-4">
|
||||
<style>{`
|
||||
@keyframes iace-running-dots {
|
||||
0% { stroke-dashoffset: 12; }
|
||||
100% { stroke-dashoffset: 0; }
|
||||
}
|
||||
.iace-transfer-dots {
|
||||
animation: iace-running-dots 0.8s linear infinite;
|
||||
}
|
||||
`}</style>
|
||||
<svg width="80" height="32" viewBox="0 0 80 32" className="overflow-visible">
|
||||
{/* Background line */}
|
||||
<line
|
||||
x1="0"
|
||||
y1="16"
|
||||
x2="80"
|
||||
y2="16"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeOpacity="0.3"
|
||||
/>
|
||||
{/* Animated running dots */}
|
||||
<line
|
||||
x1="0"
|
||||
y1="16"
|
||||
x2="80"
|
||||
y2="16"
|
||||
stroke={color}
|
||||
strokeWidth="2.5"
|
||||
strokeDasharray="4 8"
|
||||
className="iace-transfer-dots"
|
||||
/>
|
||||
{/* Arrowhead */}
|
||||
<polygon
|
||||
points="74,11 80,16 74,21"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
{/* Label */}
|
||||
{transfer.label && (
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400 mt-1 text-center leading-tight max-w-[80px] truncate">
|
||||
{transfer.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { AggregatePanel } from './_components/AggregatePanel'
|
||||
import { StationCard } from './_components/StationCard'
|
||||
import { TransferLine } from './_components/TransferLine'
|
||||
import type { LineDashboard, StationDashboard, TransferInfo } from '../_types'
|
||||
import { TRANSFER_COLORS } from '../_types'
|
||||
|
||||
/** Number of stations per visual row before wrapping */
|
||||
const STATIONS_PER_ROW = 4
|
||||
|
||||
export default function LineDashboardPage() {
|
||||
const params = useParams()
|
||||
const lineId = params.lineId as string
|
||||
const [dashboard, setDashboard] = useState<LineDashboard | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedStation, setExpandedStation] = useState<string | null>(null)
|
||||
|
||||
const fetchDashboard = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/iace/production-lines/${lineId}/dashboard`)
|
||||
if (!res.ok) {
|
||||
setError('Produktionslinie konnte nicht geladen werden')
|
||||
return
|
||||
}
|
||||
const json = await res.json()
|
||||
setDashboard(json)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch line dashboard:', err)
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [lineId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard()
|
||||
}, [fetchDashboard])
|
||||
|
||||
function handleToggle(stationId: string) {
|
||||
setExpandedStation((prev) => (prev === stationId ? null : stationId))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !dashboard) {
|
||||
return (
|
||||
<div className="text-center py-12 space-y-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{error || 'Produktionslinie nicht gefunden'}
|
||||
</h2>
|
||||
<Link href="/sdk/iace/lines" className="text-purple-600 hover:text-purple-700">
|
||||
Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sortedStations = [...dashboard.stations].sort(
|
||||
(a, b) => a.station.sort_order - b.station.sort_order
|
||||
)
|
||||
|
||||
// Build rows of stations for display
|
||||
const rows = buildStationRows(sortedStations, STATIONS_PER_ROW)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-7xl mx-auto">
|
||||
{/* Back link */}
|
||||
<Link
|
||||
href="/sdk/iace/lines"
|
||||
className="inline-flex items-center gap-1 text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300 font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Alle Produktionslinien
|
||||
</Link>
|
||||
|
||||
{/* Aggregate panel */}
|
||||
<AggregatePanel dashboard={dashboard} />
|
||||
|
||||
{/* Station flow */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h2 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Stationsuebersicht
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 overflow-x-auto">
|
||||
{rows.map((row, rowIndex) => (
|
||||
<StationRow
|
||||
key={rowIndex}
|
||||
stations={row}
|
||||
transfers={dashboard.transfers}
|
||||
expandedStation={expandedStation}
|
||||
onToggle={handleToggle}
|
||||
reversed={rowIndex % 2 === 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Split sorted stations into rows of N for layout */
|
||||
function buildStationRows(
|
||||
stations: StationDashboard[],
|
||||
perRow: number
|
||||
): StationDashboard[][] {
|
||||
const rows: StationDashboard[][] = []
|
||||
for (let i = 0; i < stations.length; i += perRow) {
|
||||
rows.push(stations.slice(i, i + perRow))
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Find the transfer between two adjacent station sort orders */
|
||||
function findTransfer(
|
||||
transfers: TransferInfo[],
|
||||
fromOrder: number,
|
||||
toOrder: number
|
||||
): TransferInfo | null {
|
||||
return (
|
||||
transfers.find(
|
||||
(t) => t.from_station === fromOrder && t.to_station === toOrder
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
/** Default transfer for stations without an explicit transfer entry */
|
||||
function defaultTransfer(from: number, to: number): TransferInfo {
|
||||
return { from_station: from, to_station: to, type: 'conveyor', label: '' }
|
||||
}
|
||||
|
||||
interface StationRowProps {
|
||||
stations: StationDashboard[]
|
||||
transfers: TransferInfo[]
|
||||
expandedStation: string | null
|
||||
onToggle: (id: string) => void
|
||||
reversed: boolean
|
||||
}
|
||||
|
||||
function StationRow({ stations, transfers, expandedStation, onToggle, reversed }: StationRowProps) {
|
||||
// Reverse even rows for a serpentine layout
|
||||
const ordered = reversed ? [...stations].reverse() : stations
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-0 overflow-x-auto pb-2">
|
||||
{ordered.map((station, idx) => {
|
||||
const nextStation = ordered[idx + 1]
|
||||
const transfer = nextStation
|
||||
? findTransfer(
|
||||
transfers,
|
||||
station.station.sort_order,
|
||||
nextStation.station.sort_order
|
||||
) ||
|
||||
findTransfer(
|
||||
transfers,
|
||||
nextStation.station.sort_order,
|
||||
station.station.sort_order
|
||||
) ||
|
||||
defaultTransfer(station.station.sort_order, nextStation.station.sort_order)
|
||||
: null
|
||||
|
||||
const transferColor = transfer
|
||||
? TRANSFER_COLORS[transfer.type] || TRANSFER_COLORS.conveyor
|
||||
: '#22C55E'
|
||||
|
||||
return (
|
||||
<React.Fragment key={station.station.id}>
|
||||
<StationCard
|
||||
station={station}
|
||||
expanded={expandedStation === station.station.id}
|
||||
onToggle={() => onToggle(station.station.id)}
|
||||
/>
|
||||
{transfer && (
|
||||
<TransferLine transfer={transfer} color={transferColor} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
export interface ProductionLine {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface StationDashboard {
|
||||
station: {
|
||||
id: string
|
||||
line_id: string
|
||||
project_id: string
|
||||
station_type: string
|
||||
station_label: string
|
||||
sort_order: number
|
||||
}
|
||||
project_name: string
|
||||
machine_type: string
|
||||
status: string
|
||||
risk_summary: Record<string, number>
|
||||
hazard_count: number
|
||||
mitigation_count: number
|
||||
completeness_pct: number
|
||||
sil_max: string
|
||||
pl_max: string
|
||||
}
|
||||
|
||||
export interface TransferInfo {
|
||||
from_station: number
|
||||
to_station: number
|
||||
type: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface LineDashboard {
|
||||
line: ProductionLine
|
||||
stations: StationDashboard[]
|
||||
transfers: TransferInfo[]
|
||||
aggregate: Record<string, number>
|
||||
}
|
||||
|
||||
export const STATION_TYPES: Record<string, { label: string; color: string; bgColor: string }> = {
|
||||
press: { label: 'Presse', color: '#EF4444', bgColor: 'bg-red-50' },
|
||||
robot: { label: 'Roboter', color: '#3B82F6', bgColor: 'bg-blue-50' },
|
||||
cobot: { label: 'Cobot', color: '#3B82F6', bgColor: 'bg-blue-50' },
|
||||
collaborative_robot: { label: 'Cobot', color: '#3B82F6', bgColor: 'bg-blue-50' },
|
||||
conveyor: { label: 'Foerderer', color: '#22C55E', bgColor: 'bg-green-50' },
|
||||
assembly: { label: 'Montage', color: '#F97316', bgColor: 'bg-orange-50' },
|
||||
milling: { label: 'Fraese', color: '#8B5CF6', bgColor: 'bg-purple-50' },
|
||||
turning: { label: 'Drehmaschine', color: '#1D4ED8', bgColor: 'bg-blue-50' },
|
||||
welding: { label: 'Schweissen', color: '#EAB308', bgColor: 'bg-yellow-50' },
|
||||
inspection: { label: 'Pruefung', color: '#06B6D4', bgColor: 'bg-cyan-50' },
|
||||
packaging: { label: 'Verpackung', color: '#92400E', bgColor: 'bg-amber-50' },
|
||||
motor: { label: 'Motor', color: '#6B7280', bgColor: 'bg-gray-50' },
|
||||
electric_motor: { label: 'Elektromotor', color: '#6B7280', bgColor: 'bg-gray-50' },
|
||||
rotary_transfer: { label: 'Rundtakt', color: '#7C3AED', bgColor: 'bg-violet-50' },
|
||||
rotary_transfer_machine: { label: 'Rundtakt', color: '#7C3AED', bgColor: 'bg-violet-50' },
|
||||
}
|
||||
|
||||
export const TRANSFER_COLORS: Record<string, string> = {
|
||||
conveyor: '#22C55E',
|
||||
robot: '#3B82F6',
|
||||
manual: '#EAB308',
|
||||
crane: '#F97316',
|
||||
agv: '#8B5CF6',
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface ProductionLineItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
station_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ProjectItem {
|
||||
id: string
|
||||
machine_name: string
|
||||
machine_type: string
|
||||
status: string
|
||||
}
|
||||
|
||||
const STATION_TYPES = [
|
||||
{ value: 'press', label: 'Presse' },
|
||||
{ value: 'cobot', label: 'Cobot/Roboter' },
|
||||
{ value: 'motor', label: 'Motor/Antrieb' },
|
||||
{ value: 'rotary_transfer', label: 'Rundtaktanlage' },
|
||||
{ value: 'conveyor', label: 'Foerderer' },
|
||||
{ value: 'assembly', label: 'Montage' },
|
||||
{ value: 'milling', label: 'Fraese' },
|
||||
{ value: 'turning', label: 'Drehmaschine' },
|
||||
{ value: 'welding', label: 'Schweissen' },
|
||||
{ value: 'inspection', label: 'Pruefung' },
|
||||
{ value: 'packaging', label: 'Verpackung' },
|
||||
]
|
||||
|
||||
export default function ProductionLinesListPage() {
|
||||
const [lines, setLines] = useState<ProductionLineItem[]>([])
|
||||
const [projects, setProjects] = useState<ProjectItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [lineName, setLineName] = useState('')
|
||||
const [lineDesc, setLineDesc] = useState('')
|
||||
const [selectedStations, setSelectedStations] = useState<Array<{ projectId: string; stationType: string }>>([])
|
||||
|
||||
useEffect(() => { fetchLines(); fetchProjects() }, [])
|
||||
|
||||
async function fetchLines() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/production-lines')
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setLines(json.lines || [])
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function fetchProjects() {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/iace/projects')
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setProjects((json.projects || []).map((p: Record<string, unknown>) => ({
|
||||
id: p.id, machine_name: p.machine_name, machine_type: p.machine_type, status: p.status,
|
||||
})))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function addStation() {
|
||||
setSelectedStations((prev) => [...prev, { projectId: '', stationType: 'assembly' }])
|
||||
}
|
||||
|
||||
function removeStation(idx: number) {
|
||||
setSelectedStations((prev) => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
function updateStation(idx: number, field: 'projectId' | 'stationType', value: string) {
|
||||
setSelectedStations((prev) => prev.map((s, i) => i === idx ? { ...s, [field]: value } : s))
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!lineName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
// 1. Create the line
|
||||
const lineRes = await fetch('/api/sdk/v1/iace/production-lines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: lineName.trim(), description: lineDesc.trim() }),
|
||||
})
|
||||
if (!lineRes.ok) return
|
||||
const lineJson = await lineRes.json()
|
||||
const lineId = lineJson.line?.id || lineJson.id
|
||||
|
||||
// 2. Add stations
|
||||
for (let i = 0; i < selectedStations.length; i++) {
|
||||
const s = selectedStations[i]
|
||||
if (!s.projectId) continue
|
||||
const proj = projects.find((p) => p.id === s.projectId)
|
||||
await fetch(`/api/sdk/v1/iace/production-lines/${lineId}/stations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: s.projectId,
|
||||
station_type: s.stationType,
|
||||
station_label: proj?.machine_name || '',
|
||||
sort_order: i + 1,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
setShowCreate(false)
|
||||
setLineName('')
|
||||
setLineDesc('')
|
||||
setSelectedStations([])
|
||||
await fetchLines()
|
||||
} catch (err) {
|
||||
console.error('Failed to create line:', err)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-6xl mx-auto">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<Link href="/sdk/iace" className="text-xs text-purple-600 hover:text-purple-700 font-medium flex items-center gap-1 mb-1">
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
IACE
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Produktionslinien</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">Verkettete Fertigungsstrassen mit aggregierter Risikoansicht</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Neue Produktionslinie
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-purple-200 p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Neue Produktionslinie erstellen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name *</label>
|
||||
<input type="text" value={lineName} onChange={(e) => setLineName(e.target.value)}
|
||||
placeholder="z.B. Fertigungsstrasse Halle 3"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<input type="text" value={lineDesc} onChange={(e) => setLineDesc(e.target.value)}
|
||||
placeholder="Optionale Beschreibung"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stations */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Stationen (Projekte zuordnen)</label>
|
||||
<button onClick={addStation} className="text-xs text-purple-600 hover:text-purple-700 font-medium">+ Station hinzufuegen</button>
|
||||
</div>
|
||||
{selectedStations.length === 0 && (
|
||||
<p className="text-xs text-gray-400 italic">Noch keine Stationen. Klicken Sie "+ Station hinzufuegen" um Projekte zuzuordnen.</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{selectedStations.map((s, i) => (
|
||||
<div key={i} className="flex items-center gap-2 p-2 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<span className="text-xs font-bold text-gray-400 w-6">{i + 1}.</span>
|
||||
<select value={s.projectId} onChange={(e) => updateStation(i, 'projectId', e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 text-sm border border-gray-300 rounded-lg dark:bg-gray-600 dark:border-gray-500 dark:text-white">
|
||||
<option value="">-- Projekt waehlen --</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.machine_name} ({p.machine_type})</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={s.stationType} onChange={(e) => updateStation(i, 'stationType', e.target.value)}
|
||||
className="w-40 px-2 py-1.5 text-sm border border-gray-300 rounded-lg dark:bg-gray-600 dark:border-gray-500 dark:text-white">
|
||||
{STATION_TYPES.map((t) => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => removeStation(i)} className="p-1 text-red-400 hover:text-red-600">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button onClick={handleCreate} disabled={!lineName.trim() || creating}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${lineName.trim() && !creating ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'}`}>
|
||||
{creating ? 'Wird erstellt...' : 'Produktionslinie erstellen'}
|
||||
</button>
|
||||
<button onClick={() => { setShowCreate(false); setSelectedStations([]) }}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lines list */}
|
||||
{lines.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{lines.map((line) => (
|
||||
<Link key={line.id} href={`/sdk/iace/lines/${line.id}`}
|
||||
className="block bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md hover:border-purple-300 transition-all">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">{line.name}</h3>
|
||||
{line.description && <p className="text-sm text-gray-500 mb-3 line-clamp-2">{line.description}</p>}
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<span>{line.station_count || 0} Stationen</span>
|
||||
<span>Aktualisiert: {new Date(line.updated_at || line.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lines.length === 0 && !showCreate && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Noch keine Produktionslinien</h3>
|
||||
<p className="mt-2 text-gray-500 max-w-lg mx-auto">
|
||||
Produktionslinien verketten mehrere CE-Projekte zu einer Fertigungsstrasse.
|
||||
</p>
|
||||
<button onClick={() => { setShowCreate(true); addStation() }}
|
||||
className="mt-6 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium">
|
||||
Erste Produktionslinie erstellen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ProcessFlow } from './_components/ProcessFlow'
|
||||
|
||||
interface IACEProject {
|
||||
id: string
|
||||
@@ -10,7 +11,7 @@ interface IACEProject {
|
||||
manufacturer: string
|
||||
status: string
|
||||
completeness_pct: number
|
||||
risk_summary: {
|
||||
risk_summary?: {
|
||||
critical: number
|
||||
high: number
|
||||
medium: number
|
||||
@@ -54,34 +55,35 @@ function CompletenessBar({ pct }: { pct: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RiskDots({ summary }: { summary: IACEProject['risk_summary'] }) {
|
||||
function RiskDots({ summary }: { summary?: IACEProject['risk_summary'] }) {
|
||||
const s = summary || { critical: 0, high: 0, medium: 0, low: 0 }
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
{summary.critical > 0 && (
|
||||
{s.critical > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
|
||||
<span className="text-gray-600">{summary.critical}</span>
|
||||
<span className="text-gray-600">{s.critical}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.high > 0 && (
|
||||
{s.high > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-orange-500" />
|
||||
<span className="text-gray-600">{summary.high}</span>
|
||||
<span className="text-gray-600">{s.high}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.medium > 0 && (
|
||||
{s.medium > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-yellow-500" />
|
||||
<span className="text-gray-600">{summary.medium}</span>
|
||||
<span className="text-gray-600">{s.medium}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.low > 0 && (
|
||||
{s.low > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-green-500" />
|
||||
<span className="text-gray-600">{summary.low}</span>
|
||||
<span className="text-gray-600">{s.low}</span>
|
||||
</span>
|
||||
)}
|
||||
{summary.critical === 0 && summary.high === 0 && summary.medium === 0 && summary.low === 0 && (
|
||||
{s.critical === 0 && s.high === 0 && s.medium === 0 && s.low === 0 && (
|
||||
<span className="text-gray-400">Keine Risiken</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,7 +144,13 @@ export default function IACEDashboardPage() {
|
||||
const res = await fetch('/api/sdk/v1/iace/projects')
|
||||
if (res.ok) {
|
||||
const json = await res.json()
|
||||
setProjects(json.projects || json || [])
|
||||
const raw = json.projects || json || []
|
||||
// Map API fields to frontend expectations
|
||||
setProjects(raw.map((p: Record<string, unknown>) => ({
|
||||
...p,
|
||||
completeness_pct: p.completeness_pct ?? p.completeness_score ?? 0,
|
||||
risk_summary: p.risk_summary || { critical: 0, high: 0, medium: 0, low: 0 },
|
||||
})))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch IACE projects:', err)
|
||||
@@ -219,6 +227,36 @@ export default function IACEDashboardPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Production Lines Quick Access */}
|
||||
<Link
|
||||
href="/sdk/iace/lines"
|
||||
className="block bg-gradient-to-r from-purple-50 to-indigo-50 dark:from-purple-900/20 dark:to-indigo-900/20 rounded-xl border border-purple-200 dark:border-purple-800 p-6 hover:shadow-md hover:border-purple-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-purple-100 dark:bg-purple-900/40 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Produktionslinien
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Verkettete Fertigungsstrassen mit aggregierter Risikoansicht und animiertem Stationsfluss
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-5 h-5 text-purple-400 group-hover:text-purple-600 transition-colors flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Process Flow */}
|
||||
<ProcessFlow />
|
||||
|
||||
{/* Create Form */}
|
||||
{showCreateForm && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
|
||||
@@ -5,8 +5,9 @@ import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { SDKProvider } from '@/lib/sdk'
|
||||
import { SDKSidebar } from '@/components/sdk/Sidebar/SDKSidebar'
|
||||
import { CommandBar } from '@/components/sdk/CommandBar'
|
||||
import { SDKPipelineSidebar } from '@/components/sdk/SDKPipelineSidebar'
|
||||
// SDKPipelineSidebar removed — replaced by per-module FAB navigators
|
||||
import { ComplianceAdvisorWidget } from '@/components/sdk/ComplianceAdvisorWidget'
|
||||
import { CookieBannerOverlay, CookieBannerFAB } from '@/components/sdk/CookieBannerOverlay'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
@@ -207,11 +208,14 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) {
|
||||
{/* Command Bar Modal */}
|
||||
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
|
||||
|
||||
{/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */}
|
||||
{projectId && <SDKPipelineSidebar />}
|
||||
{/* Module-specific FAB navigators are rendered by each module's layout */}
|
||||
|
||||
{/* Compliance Advisor Widget */}
|
||||
{projectId && <ComplianceAdvisorWidget currentStep={currentStep} />}
|
||||
{/* Compliance Advisor Widget — immer sichtbar, auch ohne Projekt */}
|
||||
<ComplianceAdvisorWidget currentStep={currentStep} />
|
||||
|
||||
{/* Cookie Banner — opens on first visit, reopenable via FAB */}
|
||||
<CookieBannerOverlay />
|
||||
<CookieBannerFAB />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
|
||||
import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector'
|
||||
import { RegulatoryNewsFeed } from '@/components/sdk/regulatory-news/RegulatoryNewsFeed'
|
||||
import type { SDKPackageId } from '@/lib/sdk/types'
|
||||
|
||||
// =============================================================================
|
||||
@@ -331,6 +332,9 @@ export default function SDKDashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Regulatory News */}
|
||||
<RegulatoryNewsFeed businessModel={state.companyProfile?.businessModel as string} />
|
||||
|
||||
{/* 5 Packages */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
interface PaymentControl {
|
||||
control_id: string
|
||||
domain: string
|
||||
title: string
|
||||
objective: string
|
||||
check_target: string
|
||||
evidence: string[]
|
||||
automation: string
|
||||
}
|
||||
|
||||
interface PaymentDomain {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface Assessment {
|
||||
id: string
|
||||
project_name: string
|
||||
tender_reference: string
|
||||
customer_name: string
|
||||
system_type: string
|
||||
total_controls: number
|
||||
controls_passed: number
|
||||
controls_failed: number
|
||||
controls_partial: number
|
||||
controls_not_applicable: number
|
||||
controls_not_checked: number
|
||||
compliance_score: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface TenderAnalysis {
|
||||
id: string
|
||||
file_name: string
|
||||
file_size: number
|
||||
project_name: string
|
||||
customer_name: string
|
||||
status: string
|
||||
total_requirements: number
|
||||
matched_count: number
|
||||
unmatched_count: number
|
||||
partial_count: number
|
||||
requirements?: Array<{ req_id: string; text: string; obligation_level: string; technical_domain: string; confidence: number }>
|
||||
match_results?: Array<{ req_id: string; req_text: string; verdict: string; matched_controls: Array<{ control_id: string; title: string; relevance: number }>; gap_description?: string }>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const AUTOMATION_STYLES: Record<string, { bg: string; text: string }> = {
|
||||
high: { bg: 'bg-green-100', text: 'text-green-700' },
|
||||
medium: { bg: 'bg-yellow-100', text: 'text-yellow-700' },
|
||||
partial: { bg: 'bg-orange-100', text: 'text-orange-700' },
|
||||
low: { bg: 'bg-red-100', text: 'text-red-700' },
|
||||
}
|
||||
|
||||
const TARGET_ICONS: Record<string, string> = {
|
||||
code: '💻', system: '🖥️', config: '⚙️', process: '📋',
|
||||
repository: '📦', certificate: '📜',
|
||||
}
|
||||
|
||||
export default function PaymentCompliancePage() {
|
||||
const [controls, setControls] = useState<PaymentControl[]>([])
|
||||
const [domains, setDomains] = useState<PaymentDomain[]>([])
|
||||
const [assessments, setAssessments] = useState<Assessment[]>([])
|
||||
const [tenderAnalyses, setTenderAnalyses] = useState<TenderAnalysis[]>([])
|
||||
const [selectedTender, setSelectedTender] = useState<TenderAnalysis | null>(null)
|
||||
const [selectedDomain, setSelectedDomain] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [tab, setTab] = useState<'controls' | 'assessments' | 'tender'>('controls')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [processing, setProcessing] = useState(false)
|
||||
const [showNewAssessment, setShowNewAssessment] = useState(false)
|
||||
const [newProject, setNewProject] = useState({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [ctrlResp, assessResp, tenderResp] = await Promise.all([
|
||||
fetch('/api/sdk/v1/payment-compliance?endpoint=controls'),
|
||||
fetch('/api/sdk/v1/payment-compliance?endpoint=assessments'),
|
||||
fetch('/api/sdk/v1/payment-compliance/tender'),
|
||||
])
|
||||
if (ctrlResp.ok) {
|
||||
const data = await ctrlResp.json()
|
||||
setControls(data.controls || [])
|
||||
setDomains(data.domains || [])
|
||||
}
|
||||
if (assessResp.ok) {
|
||||
const data = await assessResp.json()
|
||||
setAssessments(data.assessments || [])
|
||||
}
|
||||
if (tenderResp.ok) {
|
||||
const data = await tenderResp.json()
|
||||
setTenderAnalyses(data.analyses || [])
|
||||
}
|
||||
} catch {}
|
||||
finally { setLoading(false) }
|
||||
}
|
||||
|
||||
async function handleTenderUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('project_name', file.name.replace(/\.[^.]+$/, ''))
|
||||
const resp = await fetch('/api/sdk/v1/payment-compliance/tender', { method: 'POST', body: formData })
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
// Auto-start extraction + matching
|
||||
setProcessing(true)
|
||||
const extractResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=extract`, { method: 'POST' })
|
||||
if (extractResp.ok) {
|
||||
await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}?action=match`, { method: 'POST' })
|
||||
}
|
||||
// Reload and show result
|
||||
const detailResp = await fetch(`/api/sdk/v1/payment-compliance/tender/${data.id}`)
|
||||
if (detailResp.ok) {
|
||||
const detail = await detailResp.json()
|
||||
setSelectedTender(detail)
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
} catch {} finally {
|
||||
setUploading(false)
|
||||
setProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewTender(id: string) {
|
||||
const resp = await fetch(`/api/sdk/v1/payment-compliance/tender/${id}`)
|
||||
if (resp.ok) {
|
||||
setSelectedTender(await resp.json())
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateAssessment() {
|
||||
const resp = await fetch('/api/sdk/v1/payment-compliance', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newProject),
|
||||
})
|
||||
if (resp.ok) {
|
||||
setShowNewAssessment(false)
|
||||
setNewProject({ project_name: '', tender_reference: '', customer_name: '', system_type: 'full_stack' })
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredControls = selectedDomain === 'all'
|
||||
? controls
|
||||
: controls.filter(c => c.domain === selectedDomain)
|
||||
|
||||
const domainStats = domains.map(d => ({
|
||||
...d,
|
||||
count: controls.filter(c => c.domain === d.id).length,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Payment Terminal Compliance</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Technische Pruefbibliothek fuer Zahlungssysteme — {controls.length} Controls in {domains.length} Domaenen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setTab('controls')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'controls' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Controls ({controls.length})
|
||||
</button>
|
||||
<button onClick={() => setTab('assessments')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'assessments' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Assessments ({assessments.length})
|
||||
</button>
|
||||
<button onClick={() => setTab('tender')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${tab === 'tender' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-700'}`}>
|
||||
Ausschreibung ({tenderAnalyses.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-xl text-sm text-blue-800">
|
||||
<div className="font-semibold mb-2">Wie funktioniert Payment Terminal Compliance?</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="font-medium mb-1">1. Controls durchsuchen</div>
|
||||
<p className="text-xs text-blue-700">Unsere Bibliothek enthaelt {controls.length} technische Pruefregeln fuer Zahlungssysteme — von Transaktionslogik ueber Kryptographie bis ZVT/OPI-Protokollverhalten. Jeder Control definiert was geprueft wird und welche Evidenz noetig ist.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">2. Assessment erstellen</div>
|
||||
<p className="text-xs text-blue-700">Ein Assessment ist eine projektbezogene Pruefung — z.B. fuer eine bestimmte Ausschreibung oder einen Kunden. Sie ordnet jedem Control einen Status zu: bestanden, fehlgeschlagen, teilweise oder nicht anwendbar.</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium mb-1">3. Ausschreibung analysieren</div>
|
||||
<p className="text-xs text-blue-700">Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch die Anforderungen und matcht sie gegen unsere Controls. Ergebnis: Welche Anforderungen sind abgedeckt und wo gibt es Luecken.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-500">Lade...</div>
|
||||
) : tab === 'controls' ? (
|
||||
<>
|
||||
{/* Domain Filter */}
|
||||
<div className="grid grid-cols-5 gap-3 mb-6">
|
||||
<button onClick={() => setSelectedDomain('all')}
|
||||
className={`p-3 rounded-xl border text-center ${selectedDomain === 'all' ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<div className="text-lg font-bold text-purple-700">{controls.length}</div>
|
||||
<div className="text-xs text-gray-500">Alle</div>
|
||||
</button>
|
||||
{domainStats.map(d => (
|
||||
<button key={d.id} onClick={() => setSelectedDomain(d.id)}
|
||||
className={`p-3 rounded-xl border text-center ${selectedDomain === d.id ? 'border-purple-500 bg-purple-50' : 'border-gray-200 hover:border-purple-300'}`}>
|
||||
<div className="text-lg font-bold text-gray-900">{d.count}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{d.id}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Domain Description */}
|
||||
{selectedDomain !== 'all' && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-800">
|
||||
<strong>{domains.find(d => d.id === selectedDomain)?.name}:</strong>{' '}
|
||||
{domains.find(d => d.id === selectedDomain)?.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls List */}
|
||||
<div className="space-y-3">
|
||||
{filteredControls.map(ctrl => {
|
||||
const autoStyle = AUTOMATION_STYLES[ctrl.automation] || AUTOMATION_STYLES.low
|
||||
return (
|
||||
<div key={ctrl.control_id} className="bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-purple-600 bg-purple-50 px-2 py-0.5 rounded">{ctrl.control_id}</span>
|
||||
<span className="text-xs text-gray-400">{TARGET_ICONS[ctrl.check_target] || '🔍'} {ctrl.check_target}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${autoStyle.bg} ${autoStyle.text}`}>
|
||||
{ctrl.automation}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">{ctrl.title}</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">{ctrl.objective}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-2">
|
||||
{ctrl.evidence.map(ev => (
|
||||
<span key={ev} className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">{ev}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : tab === 'assessments' ? (
|
||||
<>
|
||||
{/* Assessments Tab */}
|
||||
<div className="mb-4">
|
||||
<button onClick={() => setShowNewAssessment(true)}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||
+ Neues Assessment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showNewAssessment && (
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Neues Payment Compliance Assessment</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Projektname *</label>
|
||||
<input value={newProject.project_name} onChange={e => setNewProject(p => ({ ...p, project_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Ausschreibung Muenchen 2026" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ausschreibungs-Referenz</label>
|
||||
<input value={newProject.tender_reference} onChange={e => setNewProject(p => ({ ...p, tender_reference: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. 2026-PAY-001" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
|
||||
<input value={newProject.customer_name} onChange={e => setNewProject(p => ({ ...p, customer_name: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" placeholder="z.B. Stadt Muenchen" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Systemtyp</label>
|
||||
<select value={newProject.system_type} onChange={e => setNewProject(p => ({ ...p, system_type: e.target.value }))}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
|
||||
<option value="full_stack">Full Stack (Terminal + Backend)</option>
|
||||
<option value="terminal">Nur Terminal</option>
|
||||
<option value="backend">Nur Backend</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<button onClick={handleCreateAssessment} disabled={!newProject.project_name}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">Erstellen</button>
|
||||
<button onClick={() => setShowNewAssessment(false)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{assessments.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p className="text-lg mb-2">Noch keine Assessments</p>
|
||||
<p className="text-sm">Erstelle ein neues Assessment fuer eine Ausschreibung.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{assessments.map(a => (
|
||||
<div key={a.id} className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{a.project_name}</h3>
|
||||
<div className="text-sm text-gray-500">
|
||||
{a.customer_name && <span>{a.customer_name} · </span>}
|
||||
{a.tender_reference && <span>Ref: {a.tender_reference} · </span>}
|
||||
<span>{new Date(a.created_at).toLocaleDateString('de-DE')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
a.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
a.status === 'in_progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}>{a.status}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold">{a.total_controls}</div>
|
||||
<div className="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-green-50 rounded">
|
||||
<div className="text-lg font-bold text-green-700">{a.controls_passed}</div>
|
||||
<div className="text-xs text-gray-500">Passed</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-red-50 rounded">
|
||||
<div className="text-lg font-bold text-red-700">{a.controls_failed}</div>
|
||||
<div className="text-xs text-gray-500">Failed</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-yellow-50 rounded">
|
||||
<div className="text-lg font-bold text-yellow-700">{a.controls_partial}</div>
|
||||
<div className="text-xs text-gray-500">Partial</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold text-gray-400">{a.controls_not_applicable}</div>
|
||||
<div className="text-xs text-gray-500">N/A</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-gray-50 rounded">
|
||||
<div className="text-lg font-bold text-gray-400">{a.controls_not_checked}</div>
|
||||
<div className="text-xs text-gray-500">Offen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : tab === 'tender' ? (
|
||||
<>
|
||||
{/* Tender Analysis Tab */}
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border-2 border-dashed border-purple-300 text-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Ausschreibung analysieren</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Laden Sie ein Ausschreibungsdokument hoch. Die KI extrahiert automatisch alle Anforderungen und matcht sie gegen die Control-Bibliothek.
|
||||
</p>
|
||||
<label className="inline-block px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 cursor-pointer">
|
||||
{uploading ? 'Hochladen...' : processing ? 'Analysiere...' : 'PDF / Dokument hochladen'}
|
||||
<input type="file" className="hidden" accept=".pdf,.txt,.doc,.docx" onChange={handleTenderUpload} disabled={uploading || processing} />
|
||||
</label>
|
||||
<p className="text-xs text-gray-400 mt-2">PDF, TXT oder Word. Max 50 MB. Dokument wird nur fuer diese Analyse verwendet.</p>
|
||||
</div>
|
||||
|
||||
{/* Selected Tender Detail */}
|
||||
{selectedTender && (
|
||||
<div className="mb-6 p-6 bg-white rounded-xl border border-purple-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{selectedTender.project_name}</h3>
|
||||
<p className="text-sm text-gray-500">{selectedTender.file_name} — {selectedTender.status}</p>
|
||||
</div>
|
||||
<button onClick={() => setSelectedTender(null)} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold">{selectedTender.total_requirements}</div>
|
||||
<div className="text-xs text-gray-500">Anforderungen</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-700">{selectedTender.matched_count}</div>
|
||||
<div className="text-xs text-gray-500">Abgedeckt</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-yellow-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-700">{selectedTender.partial_count}</div>
|
||||
<div className="text-xs text-gray-500">Teilweise</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-700">{selectedTender.unmatched_count}</div>
|
||||
<div className="text-xs text-gray-500">Luecken</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match Results */}
|
||||
{selectedTender.match_results && selectedTender.match_results.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-gray-900">Requirement → Control Matching</h4>
|
||||
{selectedTender.match_results.map((mr, idx) => (
|
||||
<div key={idx} className={`p-4 rounded-lg border ${
|
||||
mr.verdict === 'matched' ? 'border-green-200 bg-green-50' :
|
||||
mr.verdict === 'partial' ? 'border-yellow-200 bg-yellow-50' :
|
||||
'border-red-200 bg-red-50'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono bg-white px-2 py-0.5 rounded border">{mr.req_id}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
mr.verdict === 'matched' ? 'bg-green-200 text-green-800' :
|
||||
mr.verdict === 'partial' ? 'bg-yellow-200 text-yellow-800' :
|
||||
'bg-red-200 text-red-800'
|
||||
}`}>
|
||||
{mr.verdict === 'matched' ? 'Abgedeckt' : mr.verdict === 'partial' ? 'Teilweise' : 'Luecke'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-900">{mr.req_text}</p>
|
||||
</div>
|
||||
</div>
|
||||
{mr.matched_controls && mr.matched_controls.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{mr.matched_controls.map(mc => (
|
||||
<span key={mc.control_id} className="text-xs bg-white border px-2 py-0.5 rounded">
|
||||
{mc.control_id} ({Math.round(mc.relevance * 100)}%)
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{mr.gap_description && (
|
||||
<p className="text-xs text-orange-700 mt-2">{mr.gap_description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Previous Analyses */}
|
||||
{tenderAnalyses.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-semibold text-gray-900 mb-3">Bisherige Analysen</h4>
|
||||
<div className="space-y-3">
|
||||
{tenderAnalyses.map(ta => (
|
||||
<button key={ta.id} onClick={() => handleViewTender(ta.id)}
|
||||
className="w-full text-left bg-white rounded-xl border border-gray-200 p-4 hover:border-purple-300 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{ta.project_name}</h3>
|
||||
<p className="text-xs text-gray-500">{ta.file_name} — {new Date(ta.created_at).toLocaleDateString('de-DE')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">{ta.matched_count} matched</span>
|
||||
{ta.unmatched_count > 0 && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-0.5 rounded-full">{ta.unmatched_count} gaps</span>
|
||||
)}
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
ta.status === 'matched' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'
|
||||
}`}>{ta.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -250,4 +250,95 @@ export const STEPS_BETRIEB: SDKFlowStep[] = [
|
||||
url: '/sdk/isms',
|
||||
completion: 100,
|
||||
},
|
||||
|
||||
// ── Control Pipeline ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'control-library',
|
||||
name: 'Canonical Control Library',
|
||||
nameShort: 'Control Library',
|
||||
package: 'betrieb',
|
||||
seq: 5200,
|
||||
checkpointId: 'CP-CLIB',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Verwaltung der ~33.000 Rich Controls aus dem RAG-Korpus. 7-Stufen-Pipeline mit Lizenz-Gate.',
|
||||
descriptionLong: 'Die Canonical Control Library ist das zentrale Verzeichnis aller aus Regulierungstexten generierten Compliance Controls. Die 7-Stufen-Pipeline verarbeitet ~105.000 RAG-Chunks: (1) RAG Scan, (2) Lizenz-Klassifikation (Rule 1/2/3), (3a) Strukturierung (Rule 1+2) oder (3b) Reformulierung (Rule 3), (4) Harmonisierung (Embedding-Dedup), (5) Anchor Search (Open-Source-Referenzen), (6) Speicherung, (7) Chunk-Tracking. Domains: AUTH, CRYP, NET, DATA, SEC, AI, COMP, GOV, LAB, FIN u.a.',
|
||||
legalBasis: 'UrhG §44b (Text & Data Mining), UrhG §23 (Hinreichender Abstand)',
|
||||
inputs: ['ragChunks'],
|
||||
outputs: ['canonicalControls'],
|
||||
prerequisiteSteps: [],
|
||||
dbTables: ['canonical_controls', 'canonical_processed_chunks', 'canonical_generation_jobs'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: ['bp_compliance_gesetze', 'bp_compliance_datenschutz', 'bp_compliance_ce', 'bp_dsfa_corpus', 'bp_legal_templates'],
|
||||
ragPurpose: 'Quelldokumente fuer Control-Generierung (Gesetze, Verordnungen, Standards)',
|
||||
isOptional: false,
|
||||
url: '/sdk/control-library',
|
||||
completion: 100,
|
||||
},
|
||||
{
|
||||
id: 'obligation-extraction',
|
||||
name: 'Pass 0a: Obligation Extraction',
|
||||
nameShort: 'Pass 0a',
|
||||
package: 'betrieb',
|
||||
seq: 5300,
|
||||
checkpointId: 'CP-P0A',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Extraktion von ~181.000 normativen Pflichten aus Rich Controls via Claude Haiku (Batch API).',
|
||||
descriptionLong: 'Pass 0a zerlegt jeden Rich Control in einzelne normative Obligations via Claude Haiku (Anthropic Batch API, 50% Kostenreduktion). Jede Obligation wird klassifiziert: Pflicht/Empfehlung/Kann, Test-Obligation ja/nein, Reporting-Obligation ja/nein. Quality Gate mit 6 Regeln: nur normative Aussagen, ein Hauptverb, Test/Reporting separat, kein Evidence-Level-Split. Ergebnis: ~181.000 validierte Obligations mit action, object, condition, normative_strength.',
|
||||
legalBasis: 'Pipeline-intern (Normative Obligation Extraction)',
|
||||
inputs: ['canonicalControls'],
|
||||
outputs: ['obligationCandidates'],
|
||||
prerequisiteSteps: ['control-library'],
|
||||
dbTables: ['obligation_candidates'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: false,
|
||||
url: '/sdk/control-library',
|
||||
completion: 90,
|
||||
},
|
||||
{
|
||||
id: 'atomic-composition',
|
||||
name: 'Pass 0b: Atomic Composition',
|
||||
nameShort: 'Pass 0b',
|
||||
package: 'betrieb',
|
||||
seq: 5400,
|
||||
checkpointId: 'CP-P0B',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Komposition atomarer MCP-tauglicher Controls aus Obligations via Claude Sonnet + Pre-LLM Ontology-Filter.',
|
||||
descriptionLong: 'Pass 0b verwandelt jede validierte Obligation in ein eigenstaendiges atomares Control via Claude Sonnet (Anthropic Batch API). Vor dem LLM-Call klassifiziert die Control Ontology (26 Action Types) jede Obligation: atomic (an LLM senden), composite (ueberspringen), evidence (ueberspringen), framework_container (ueberspringen). MCP-taugliche Output-Felder: assertion (pruefbare Aussage), pass_criteria, fail_criteria, check_type (technical_config_check, document_clause_check, code_pattern_check), dependency_hints, lifecycle_phase_order (1-13). Canonical Key Format: action_type:normalized_object:control_phase.',
|
||||
legalBasis: 'Pipeline-intern (Atomic Control Composition)',
|
||||
inputs: ['obligationCandidates'],
|
||||
outputs: ['atomicControls'],
|
||||
prerequisiteSteps: ['obligation-extraction'],
|
||||
dbTables: ['canonical_controls', 'control_parent_links'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: false,
|
||||
url: '/sdk/control-library',
|
||||
completion: 80,
|
||||
},
|
||||
{
|
||||
id: 'dependency-engine',
|
||||
name: 'Dependency Engine + Evaluation',
|
||||
nameShort: 'Dependencies',
|
||||
package: 'betrieb',
|
||||
seq: 5500,
|
||||
checkpointId: 'CP-DEP',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: '5 Dependency-Typen, generische Condition Language, automatische Generierung via Ontology + Domain Packs.',
|
||||
descriptionLong: 'Die Dependency Engine modelliert logische Abhaengigkeiten zwischen Controls: supersedes (A ersetzt B), prerequisite (A muss vor B), compensating_control (A kompensiert B-Failure), scope_exclusion (A schliesst B aus), conditional_requirement (B nur unter Bedingung). Generische Condition Language (AND/OR/NOT + Feldoperatoren). Priority-basierte Konfliktloesung. Zykluserkennung (DFS). Automatische Generierung via: (1) Ontology (Phase-Sequenz), (2) Pattern-Regeln, (3) Domain Packs (DSGVO, AI Act, CRA, Security, Arbeitsrecht). MCP-Output mit dependency_resolution Trace.',
|
||||
legalBasis: 'Pipeline-intern (Control Dependency Resolution)',
|
||||
inputs: ['atomicControls'],
|
||||
outputs: ['evaluatedControls', 'dependencyGraph'],
|
||||
prerequisiteSteps: ['atomic-composition'],
|
||||
dbTables: ['control_dependencies', 'control_evaluation_results'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: false,
|
||||
url: '/sdk/control-library',
|
||||
completion: 100,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -53,7 +53,7 @@ export const STEPS_VORBEREITUNG: SDKFlowStep[] = [
|
||||
checkpointId: 'CP-UC',
|
||||
checkpointType: 'REQUIRED',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl.',
|
||||
description: 'Systematische Erfassung aller Datenverarbeitungs- und KI-Anwendungsfaelle ueber einen 8-Schritte-Wizard mit Kachel-Auswahl. Inkl. BetrVG-Mitbestimmungspruefung und Betriebsrats-Konflikt-Score.',
|
||||
descriptionLong: 'In einem 8-Schritte-Wizard werden alle Use Cases erfasst: (1) Grundlegendes — Titel, Beschreibung, KI-Kategorie (21 Kacheln), Branche wird automatisch aus dem Profil abgeleitet. (2) Datenkategorien — ~60 Kategorien in 10 Gruppen als Kacheln (inkl. Art. 9 hervorgehoben). (3) Verarbeitungszweck — 16 Zweck-Kacheln, Rechtsgrundlage wird vom SDK automatisch ermittelt. (4) Automatisierungsgrad — assistiv/teilautomatisiert/vollautomatisiert. (5) Hosting & Modell — Provider, Region, Modellnutzung (Inferenz/RAG/Fine-Tuning/Training). (6) Datentransfer — Transferziele und Schutzmechanismen. (7) Datenhaltung — Aufbewahrungsfristen. (8) Vertraege — vorhandene Compliance-Dokumente. Die RAG-Collection bp_compliance_ce wird verwendet, um relevante CE-Regulierungen automatisch den Use Cases zuzuordnen (UCCA).',
|
||||
legalBasis: 'Art. 30 DSGVO (Verzeichnis von Verarbeitungstaetigkeiten)',
|
||||
inputs: ['companyProfile'],
|
||||
@@ -66,6 +66,27 @@ export const STEPS_VORBEREITUNG: SDKFlowStep[] = [
|
||||
isOptional: false,
|
||||
url: '/sdk/use-cases',
|
||||
},
|
||||
{
|
||||
id: 'ai-registration',
|
||||
name: 'EU AI Database Registrierung',
|
||||
nameShort: 'EU-Reg',
|
||||
package: 'vorbereitung',
|
||||
seq: 350,
|
||||
checkpointId: 'CP-REG',
|
||||
checkpointType: 'CONDITIONAL',
|
||||
checkpointReviewer: 'NONE',
|
||||
description: 'Registrierung von Hochrisiko-KI-Systemen in der EU AI Database gemaess Art. 49 KI-Verordnung.',
|
||||
descriptionLong: 'Fuer Hochrisiko-KI-Systeme (Annex III) ist eine Registrierung in der EU AI Database Pflicht. Ein 6-Schritte-Wizard fuehrt durch den Prozess: (1) Anbieter-Daten — Name, Rechtsform, Adresse, EU-Repraesentant. (2) System-Details — Name, Version, Beschreibung, Einsatzzweck (vorausgefuellt aus UCCA Assessment). (3) Klassifikation — Risikoklasse und Annex III Kategorie (aus Decision Tree). (4) Konformitaet — CE-Kennzeichnung, Notified Body. (5) Trainingsdaten — Zusammenfassung der Datenquellen (Art. 10). (6) Pruefung + Export — JSON-Download fuer EU-Datenbank-Submission. Der Status-Workflow ist: Entwurf → Bereit → Eingereicht → Registriert.',
|
||||
legalBasis: 'Art. 49 KI-Verordnung (EU) 2024/1689',
|
||||
inputs: ['useCases', 'companyProfile'],
|
||||
outputs: ['euRegistration'],
|
||||
prerequisiteSteps: ['use-case-assessment'],
|
||||
dbTables: ['ai_system_registrations'],
|
||||
dbMode: 'read/write',
|
||||
ragCollections: [],
|
||||
isOptional: true,
|
||||
url: '/sdk/ai-registration',
|
||||
},
|
||||
{
|
||||
id: 'import',
|
||||
name: 'Dokument-Import',
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useState, useEffect } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { AssessmentResultCard } from '@/components/sdk/use-case-assessment/AssessmentResultCard'
|
||||
import { OptimizerUpsellCard } from '@/components/sdk/compliance-optimizer/OptimizerUpsellCard'
|
||||
import { EnrichmentHints } from '@/components/sdk/assessment/EnrichmentHints'
|
||||
|
||||
interface TriggeredRule {
|
||||
code: string
|
||||
@@ -57,6 +59,8 @@ interface FullAssessment {
|
||||
dsfa_recommended: boolean
|
||||
art22_risk: boolean
|
||||
training_allowed: string
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
triggered_rules?: TriggeredRule[]
|
||||
required_controls?: RequiredControl[]
|
||||
recommended_architecture?: PatternRecommendation[]
|
||||
@@ -136,6 +140,18 @@ export default function AssessmentDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const handleOptimize = async () => {
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const res = await fetch(`/api/sdk/v1/maximizer/optimize-from-assessment/${assessmentId}`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
router.push(`/sdk/compliance-optimizer/${data.id}`)
|
||||
}
|
||||
} catch { /* silent */ } finally { setOptimizing(false) }
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -167,6 +183,8 @@ export default function AssessmentDetailPage() {
|
||||
dsfa_recommended: assessment.dsfa_recommended,
|
||||
art22_risk: assessment.art22_risk,
|
||||
training_allowed: assessment.training_allowed,
|
||||
betrvg_conflict_score: assessment.betrvg_conflict_score,
|
||||
betrvg_consultation_required: assessment.betrvg_consultation_required,
|
||||
// AssessmentResultCard expects rule_code; backend stores code — map here
|
||||
triggered_rules: assessment.triggered_rules?.map(r => ({
|
||||
rule_code: r.code,
|
||||
@@ -230,6 +248,13 @@ export default function AssessmentDetailPage() {
|
||||
>
|
||||
↓ JSON
|
||||
</a>
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{optimizing ? 'Optimiere...' : 'Optimieren'}
|
||||
</button>
|
||||
<Link
|
||||
href={`/sdk/use-cases/new?edit=${assessmentId}`}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
@@ -269,6 +294,18 @@ export default function AssessmentDetailPage() {
|
||||
{/* Result */}
|
||||
<AssessmentResultCard result={resultForCard as Parameters<typeof AssessmentResultCard>[0]['result']} />
|
||||
|
||||
{/* Enrichment Hints */}
|
||||
{assessment.enrichment_hints && (
|
||||
<EnrichmentHints hints={assessment.enrichment_hints} />
|
||||
)}
|
||||
|
||||
{/* Compliance Optimizer Upsell */}
|
||||
<OptimizerUpsellCard
|
||||
feasibility={assessment.feasibility}
|
||||
assessmentId={assessmentId}
|
||||
riskScore={assessment.risk_score}
|
||||
/>
|
||||
|
||||
{/* KI-Erklärung */}
|
||||
{assessment.explanation_text && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Assessment {
|
||||
feasibility: string
|
||||
risk_level: string
|
||||
risk_score: number
|
||||
betrvg_conflict_score?: number
|
||||
betrvg_consultation_required?: boolean
|
||||
domain: string
|
||||
created_at: string
|
||||
}
|
||||
@@ -194,6 +196,16 @@ export default function UseCasesPage() {
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${feasibility.bg} ${feasibility.text}`}>
|
||||
{feasibility.label}
|
||||
</span>
|
||||
{assessment.betrvg_conflict_score != null && assessment.betrvg_conflict_score > 0 && (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
||||
assessment.betrvg_conflict_score >= 75 ? 'bg-red-100 text-red-700' :
|
||||
assessment.betrvg_conflict_score >= 50 ? 'bg-orange-100 text-orange-700' :
|
||||
assessment.betrvg_conflict_score >= 25 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
BR {assessment.betrvg_conflict_score}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>{assessment.domain}</span>
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
CATEGORY_VENDORS, countNonEWRVendors, isEWR, isOutsideEWR,
|
||||
type VendorInfo,
|
||||
} from './cookie-banner-vendors'
|
||||
|
||||
/**
|
||||
* CookieBannerOverlay — DSGVO/CNIL-konformer Cookie-Banner mit "Nur EU/EWR" Toggle.
|
||||
*
|
||||
* Alle 4 Kategorien sind auf der ersten Ebene sichtbar (DSK OH Telemedien 2022).
|
||||
* Vendor-Details aufklappbar per Kategorie. EWR-Toggle blockiert Non-EU-Anbieter
|
||||
* auch bei aktivierter Kategorie — einzigartiges CMP-Feature.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'bp-sdk-cookie-consent'
|
||||
|
||||
interface ConsentState {
|
||||
necessary: boolean
|
||||
statistics: boolean
|
||||
marketing: boolean
|
||||
functional: boolean
|
||||
ewrOnly: boolean
|
||||
blockedVendors: string[]
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
function getStoredConsent(): ConsentState | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function CookieBannerOverlay() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [consent, setConsent] = useState<ConsentState>({
|
||||
necessary: true, statistics: false, marketing: false, functional: false,
|
||||
ewrOnly: false, blockedVendors: [], timestamp: '',
|
||||
})
|
||||
|
||||
const nonEWRCount = useMemo(() => countNonEWRVendors(), [])
|
||||
|
||||
const blockedVendors = useMemo(() => {
|
||||
if (!consent.ewrOnly) return []
|
||||
const blocked: string[] = []
|
||||
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
|
||||
const catEnabled = key === 'necessary' || consent[key as keyof ConsentState]
|
||||
if (!catEnabled) continue
|
||||
for (const v of cat.vendors) {
|
||||
if (isOutsideEWR(v.country)) blocked.push(v.name)
|
||||
}
|
||||
}
|
||||
return blocked
|
||||
}, [consent])
|
||||
|
||||
useEffect(() => {
|
||||
const stored = getStoredConsent()
|
||||
if (!stored) setIsOpen(true)
|
||||
else setConsent(stored)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setIsOpen(true)
|
||||
window.addEventListener('openCookieBanner', handler)
|
||||
return () => window.removeEventListener('openCookieBanner', handler)
|
||||
}, [])
|
||||
|
||||
const saveConsent = useCallback((state: ConsentState) => {
|
||||
const blocked: string[] = []
|
||||
if (state.ewrOnly) {
|
||||
for (const [key, cat] of Object.entries(CATEGORY_VENDORS)) {
|
||||
const catEnabled = key === 'necessary' || state[key as keyof ConsentState]
|
||||
if (!catEnabled) continue
|
||||
for (const v of cat.vendors) {
|
||||
if (isOutsideEWR(v.country)) blocked.push(v.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
const withMeta = { ...state, blockedVendors: blocked, timestamp: new Date().toISOString() }
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(withMeta))
|
||||
setConsent(withMeta)
|
||||
setIsOpen(false)
|
||||
window.dispatchEvent(new CustomEvent('sdkCookieConsentUpdated', { detail: withMeta }))
|
||||
}, [])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Non-blocking banner — no overlay, no pointer-events blocking */}
|
||||
<div className="fixed bottom-0 left-16 xl:left-64 right-0 z-50 pointer-events-none">
|
||||
<div className="max-w-3xl mx-auto m-4 bg-white rounded-2xl shadow-2xl border border-gray-200 overflow-hidden pointer-events-auto">
|
||||
|
||||
{/* Header with EWR toggle + close button */}
|
||||
<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 flex items-center justify-between">
|
||||
Cookie-Einstellungen
|
||||
<button onClick={() => setIsOpen(false)} className="text-gray-400 hover:text-gray-600 p-1" aria-label="Schliessen">
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Waehlen Sie, welche Cookie-Kategorien Sie zulassen moechten.
|
||||
</p>
|
||||
</div>
|
||||
<EWRToggle
|
||||
checked={consent.ewrOnly}
|
||||
onChange={() => setConsent(prev => ({ ...prev, ewrOnly: !prev.ewrOnly }))}
|
||||
blockedCount={blockedVendors.length}
|
||||
nonEWRCount={nonEWRCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories — always visible (CNIL/DSK compliant) */}
|
||||
<div className="px-6 pb-3 space-y-1.5 max-h-[45vh] overflow-y-auto border-t border-gray-100 pt-3">
|
||||
{Object.entries(CATEGORY_VENDORS).map(([key, cat]) => (
|
||||
<CategorySection
|
||||
key={key}
|
||||
label={cat.label}
|
||||
description={cat.description}
|
||||
vendors={cat.vendors}
|
||||
checked={key === 'necessary' ? true : consent[key as keyof ConsentState] as boolean}
|
||||
disabled={key === 'necessary'}
|
||||
ewrOnly={consent.ewrOnly}
|
||||
onChange={(v) => key !== 'necessary' && setConsent(prev => ({ ...prev, [key]: v }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Buttons — two equal-weight options */}
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => saveConsent({ ...consent, necessary: true, statistics: true, marketing: true, functional: true })}
|
||||
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"
|
||||
>
|
||||
Alle akzeptieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => saveConsent(consent)}
|
||||
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"
|
||||
>
|
||||
Auswahl speichern
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<button
|
||||
onClick={() => saveConsent({ ...consent, 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">
|
||||
<a href="/sdk/einwilligungen/privacy-policy" className="hover:text-purple-600 underline">
|
||||
Datenschutzerklaerung
|
||||
</a>
|
||||
<a href="/sdk/document-generator" className="hover:text-purple-600 underline">
|
||||
Impressum
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function CookieBannerFAB() {
|
||||
const [hasConsent, setHasConsent] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setHasConsent(!!getStoredConsent())
|
||||
const handler = () => setHasConsent(true)
|
||||
window.addEventListener('sdkCookieConsentUpdated', handler)
|
||||
return () => window.removeEventListener('sdkCookieConsentUpdated', handler)
|
||||
}, [])
|
||||
|
||||
if (!hasConsent) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new Event('openCookieBanner'))}
|
||||
className="fixed bottom-6 right-[10rem] w-14 h-14 bg-gray-700 hover:bg-gray-800 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
|
||||
aria-label="Cookie-Einstellungen oeffnen"
|
||||
title="Cookie-Einstellungen"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ─── EWR Toggle with Info Button ──────────────────────────
|
||||
|
||||
function EWRToggle({ checked, onChange, blockedCount, nonEWRCount }: {
|
||||
checked: boolean; onChange: () => void; blockedCount: number; nonEWRCount: number
|
||||
}) {
|
||||
const [showInfo, setShowInfo] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-end gap-1 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
className="w-5 h-5 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 flex items-center justify-center text-xs font-bold"
|
||||
aria-label="Info zu Nur EU/EWR"
|
||||
>
|
||||
i
|
||||
</button>
|
||||
<span className={`text-xs font-medium whitespace-nowrap ${checked ? 'text-blue-700' : 'text-gray-500'}`}>
|
||||
Nur EU/EWR
|
||||
</span>
|
||||
<button
|
||||
onClick={onChange}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 cursor-pointer ${
|
||||
checked ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{checked && blockedCount > 0 && (
|
||||
<span className="text-[10px] text-red-600 font-medium">{blockedCount} blockiert</span>
|
||||
)}
|
||||
{showInfo && (
|
||||
<div className="absolute right-0 top-12 w-72 p-3 bg-blue-50 border border-blue-200 rounded-lg shadow-lg z-10 text-xs text-blue-800 leading-relaxed">
|
||||
<div className="font-semibold mb-1">Nur EU/EWR-Anbieter</div>
|
||||
<p>
|
||||
Erlaubt nur Anbieter mit Sitz im EWR (EU + Island, Liechtenstein, Norwegen) oder
|
||||
der Schweiz. {nonEWRCount} Anbieter ausserhalb werden blockiert — auch bei
|
||||
aktivierter Cookie-Kategorie.
|
||||
</p>
|
||||
<button onClick={() => setShowInfo(false)} className="mt-2 text-blue-600 hover:text-blue-800 font-medium">
|
||||
Verstanden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// ─── Category Section with Vendor Table ───────────────────
|
||||
|
||||
function CategorySection({ label, description, vendors, checked, disabled, ewrOnly, onChange }: {
|
||||
label: string; description: string; vendors: VendorInfo[]; checked: boolean
|
||||
disabled?: boolean; ewrOnly: boolean; onChange: (v: boolean) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const nonEuVendors = vendors.filter(v => isOutsideEWR(v.country))
|
||||
const blockedCount = ewrOnly && checked ? nonEuVendors.length : 0
|
||||
const activeCount = checked ? vendors.length - blockedCount : 0
|
||||
|
||||
return (
|
||||
<div className="border border-gray-100 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-2.5 bg-gray-50/50">
|
||||
<button onClick={() => setExpanded(!expanded)} className="flex items-center gap-2 flex-1 text-left">
|
||||
<svg className={`w-3.5 h-3.5 text-gray-400 transition-transform ${expanded ? '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>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{label}
|
||||
<span className="ml-2 text-xs font-normal text-gray-400">
|
||||
{checked && blockedCount > 0
|
||||
? `${activeCount} aktiv, ${blockedCount} blockiert`
|
||||
: `${vendors.length} Verarbeiter`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{description}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
disabled={disabled}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors shrink-0 ${
|
||||
checked ? (disabled ? 'bg-gray-400' : 'bg-purple-600') : 'bg-gray-200'
|
||||
} ${disabled ? '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>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-2.5">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-gray-400 border-b border-gray-100">
|
||||
<th className="text-left py-1 font-medium w-5"></th>
|
||||
<th className="text-left py-1 font-medium">Verarbeiter</th>
|
||||
<th className="text-left py-1 font-medium">Cookies</th>
|
||||
<th className="text-left py-1 font-medium">Dauer</th>
|
||||
<th className="text-left py-1 font-medium">Land</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vendors.map((v, i) => {
|
||||
const blocked = ewrOnly && checked && isOutsideEWR(v.country)
|
||||
const active = checked && !blocked
|
||||
return (
|
||||
<tr key={i} className={`border-b border-gray-50 last:border-0 ${blocked ? 'opacity-40' : ''}`}>
|
||||
<td className="py-1 w-5">
|
||||
{blocked ? (
|
||||
<svg className="w-3.5 h-3.5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
) : active ? (
|
||||
<svg className="w-3.5 h-3.5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-3.5 h-3.5 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
<td className={`py-1 font-medium ${blocked ? 'line-through text-gray-400' : 'text-gray-700'}`}>{v.name}</td>
|
||||
<td className={`py-1 font-mono ${blocked ? 'line-through text-gray-300' : 'text-gray-500'}`}>{v.cookies}</td>
|
||||
<td className="py-1 text-gray-500">{v.retention}</td>
|
||||
<td className="py-1">
|
||||
{isOutsideEWR(v.country) ? (
|
||||
<span className={`inline-flex items-center gap-1 ${blocked ? 'text-red-400' : 'text-amber-600'}`}>
|
||||
<svg className="w-3 h-3" 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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
{v.country}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="10" strokeWidth={2} />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
{v.country}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,24 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* CMP — Consent Management Platform */}
|
||||
<div className="border-t-2 border-purple-200 py-2 bg-purple-50/30">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-semibold text-purple-600 uppercase tracking-wider">
|
||||
CMP
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem href="/sdk/cmp" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" /></svg>} label="Dashboard" isActive={pathname === '/sdk/cmp'} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/cookie-banner" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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>} label="Cookie-Banner" isActive={pathname?.startsWith('/sdk/cookie-banner') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/cookie-banner/preview" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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" /></svg>} label="Live-Vorschau" isActive={pathname === '/sdk/cookie-banner/preview'} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/einwilligungen" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" /></svg>} label="Consent-Records" isActive={pathname?.startsWith('/sdk/einwilligungen') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/consent-management" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>} label="Consent-Verwaltung" isActive={pathname === '/sdk/consent-management'} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/vendor-compliance" icon={<svg className="w-5 h-5" 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>} label="Vendor-Compliance" isActive={pathname?.startsWith('/sdk/vendor-compliance') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/dsr" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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" /></svg>} label="DSR Portal" isActive={pathname?.startsWith('/sdk/dsr') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/loeschfristen" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>} label="Loeschfristen" isActive={pathname === '/sdk/loeschfristen'} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/email-templates" icon={<svg className="w-5 h-5" 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>} label="E-Mail-Templates" isActive={pathname === '/sdk/email-templates'} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* Maschinenrecht / CE */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
@@ -42,6 +60,31 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* KI-Compliance */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
KI-Compliance
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem href="/sdk/advisory-board" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>} label="Use Case Erfassung" isActive={pathname === '/sdk/advisory-board'} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/use-cases" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>} label="Use Cases" isActive={pathname?.startsWith('/sdk/use-cases') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/ai-act" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>} label="AI Act" isActive={pathname?.startsWith('/sdk/ai-act') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* Payment / Terminal */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
<div className="px-4 py-2 text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Payment / Terminal
|
||||
</div>
|
||||
)}
|
||||
<AdditionalModuleItem href="/sdk/payment-compliance" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" /></svg>} label="Payment Compliance" isActive={pathname?.startsWith('/sdk/payment-compliance') ?? false} collapsed={collapsed} projectId={projectId} />
|
||||
</div>
|
||||
|
||||
{/* Additional Modules */}
|
||||
<div className="border-t border-gray-100 py-2">
|
||||
{!collapsed && (
|
||||
|
||||
@@ -195,6 +195,16 @@ export const STEP_EXPLANATIONS_PART2: Record<string, ExplanationEntry> = {
|
||||
{ icon: 'lightbulb' as const, title: 'Variablen', description: 'Nutzen Sie Platzhalter wie {{name}}, {{email}} und {{company}} fuer automatische Personalisierung.' },
|
||||
],
|
||||
},
|
||||
'ai-act': {
|
||||
title: 'AI Act Compliance',
|
||||
description: 'Klassifizieren Sie Ihre KI-Systeme nach dem EU AI Act',
|
||||
explanation: 'Der EU AI Act (Verordnung 2024/1689) teilt KI-Systeme in Risikoklassen ein: verboten, Hochrisiko, begrenzt und minimal. Hier registrieren Sie Ihre KI-Systeme, klassifizieren sie ueber den Decision Tree und verwalten die daraus resultierenden Pflichten. Hochrisiko-Systeme erfordern u.a. Risikomanagementsystem, technische Dokumentation, Logging und menschliche Aufsicht.',
|
||||
tips: [
|
||||
{ icon: 'warning' as const, title: 'Fristen beachten', description: 'Verbotene KI-Praktiken gelten seit Februar 2025. Hochrisiko-Pflichten greifen ab August 2026.' },
|
||||
{ icon: 'lightbulb' as const, title: 'Decision Tree nutzen', description: 'Der 2-Achsen Decision Tree (Hochrisiko + GPAI) hilft bei der systematischen Einstufung nach Annex III.' },
|
||||
{ icon: 'info' as const, title: 'GPAI-Modelle', description: 'General Purpose AI (z.B. LLMs) hat eigene Transparenz- und Sicherheitspflichten — pruefen Sie auch Axis 2.' },
|
||||
],
|
||||
},
|
||||
'use-case-workshop': {
|
||||
title: 'Use Case Workshop',
|
||||
description: 'Erfassen und bewerten Sie Ihre KI-Anwendungsfaelle im Workshop-Format',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { StepHeader, STEP_EXPLANATIONS } from './StepHeader'
|
||||
export { StepHeader } from './StepHeader'
|
||||
export { STEP_EXPLANATIONS } from './StepExplanations'
|
||||
export type { StepTip } from './StepHeader'
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface DecisionTreeQuestion {
|
||||
id: string
|
||||
axis: 'high_risk' | 'gpai'
|
||||
question: string
|
||||
description: string
|
||||
article_ref: string
|
||||
skip_if?: string
|
||||
}
|
||||
|
||||
interface DecisionTreeDefinition {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
questions: DecisionTreeQuestion[]
|
||||
}
|
||||
|
||||
interface DecisionTreeAnswer {
|
||||
question_id: string
|
||||
value: boolean
|
||||
note?: string
|
||||
}
|
||||
|
||||
interface GPAIClassification {
|
||||
is_gpai: boolean
|
||||
is_systemic_risk: boolean
|
||||
gpai_category: 'none' | 'standard' | 'systemic'
|
||||
applicable_articles: string[]
|
||||
obligations: string[]
|
||||
}
|
||||
|
||||
interface DecisionTreeResult {
|
||||
id: string
|
||||
tenant_id: string
|
||||
system_name: string
|
||||
system_description?: string
|
||||
answers: Record<string, DecisionTreeAnswer>
|
||||
high_risk_result: string
|
||||
gpai_result: GPAIClassification
|
||||
combined_obligations: string[]
|
||||
applicable_articles: string[]
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const RISK_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||
unacceptable: { label: 'Unzulässig', color: 'text-red-700', bg: 'bg-red-50', border: 'border-red-200' },
|
||||
high_risk: { label: 'Hochrisiko', color: 'text-orange-700', bg: 'bg-orange-50', border: 'border-orange-200' },
|
||||
limited_risk: { label: 'Begrenztes Risiko', color: 'text-yellow-700', bg: 'bg-yellow-50', border: 'border-yellow-200' },
|
||||
minimal_risk: { label: 'Minimales Risiko', color: 'text-green-700', bg: 'bg-green-50', border: 'border-green-200' },
|
||||
not_applicable: { label: 'Nicht anwendbar', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||||
}
|
||||
|
||||
const GPAI_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
||||
none: { label: 'Kein GPAI', color: 'text-gray-500', bg: 'bg-gray-50', border: 'border-gray-200' },
|
||||
standard: { label: 'GPAI Standard', color: 'text-blue-700', bg: 'bg-blue-50', border: 'border-blue-200' },
|
||||
systemic: { label: 'GPAI Systemisches Risiko', color: 'text-purple-700', bg: 'bg-purple-50', border: 'border-purple-200' },
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export default function DecisionTreeWizard() {
|
||||
const [definition, setDefinition] = useState<DecisionTreeDefinition | null>(null)
|
||||
const [answers, setAnswers] = useState<Record<string, DecisionTreeAnswer>>({})
|
||||
const [currentIdx, setCurrentIdx] = useState(0)
|
||||
const [systemName, setSystemName] = useState('')
|
||||
const [systemDescription, setSystemDescription] = useState('')
|
||||
const [result, setResult] = useState<DecisionTreeResult | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [phase, setPhase] = useState<'intro' | 'questions' | 'result'>('intro')
|
||||
|
||||
// Load decision tree definition
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setDefinition(data)
|
||||
} else {
|
||||
setError('Entscheidungsbaum konnte nicht geladen werden')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
// Get visible questions (respecting skip logic)
|
||||
const getVisibleQuestions = useCallback((): DecisionTreeQuestion[] => {
|
||||
if (!definition) return []
|
||||
return definition.questions.filter(q => {
|
||||
if (!q.skip_if) return true
|
||||
// Skip this question if the gate question was answered "no"
|
||||
const gateAnswer = answers[q.skip_if]
|
||||
if (gateAnswer && !gateAnswer.value) return false
|
||||
return true
|
||||
})
|
||||
}, [definition, answers])
|
||||
|
||||
const visibleQuestions = getVisibleQuestions()
|
||||
const currentQuestion = visibleQuestions[currentIdx]
|
||||
const totalVisible = visibleQuestions.length
|
||||
|
||||
const highRiskQuestions = visibleQuestions.filter(q => q.axis === 'high_risk')
|
||||
const gpaiQuestions = visibleQuestions.filter(q => q.axis === 'gpai')
|
||||
|
||||
const handleAnswer = (value: boolean) => {
|
||||
if (!currentQuestion) return
|
||||
setAnswers(prev => ({
|
||||
...prev,
|
||||
[currentQuestion.id]: {
|
||||
question_id: currentQuestion.id,
|
||||
value,
|
||||
},
|
||||
}))
|
||||
|
||||
// Auto-advance
|
||||
if (currentIdx < totalVisible - 1) {
|
||||
setCurrentIdx(prev => prev + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentIdx > 0) {
|
||||
setCurrentIdx(prev => prev - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/ucca/decision-tree/evaluate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
system_name: systemName,
|
||||
system_description: systemDescription,
|
||||
answers,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setResult(data)
|
||||
setPhase('result')
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: 'Auswertung fehlgeschlagen' }))
|
||||
setError(err.error || 'Auswertung fehlgeschlagen')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung zum Backend fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setAnswers({})
|
||||
setCurrentIdx(0)
|
||||
setSystemName('')
|
||||
setSystemDescription('')
|
||||
setResult(null)
|
||||
setPhase('intro')
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const allAnswered = visibleQuestions.every(q => answers[q.id] !== undefined)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-10 h-10 border-2 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-500">Entscheidungsbaum wird geladen...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error && !definition) {
|
||||
return (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||||
<p className="text-red-700">{error}</p>
|
||||
<p className="text-red-500 text-sm mt-2">Bitte stellen Sie sicher, dass der AI Compliance SDK Service läuft.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// INTRO PHASE
|
||||
// =========================================================================
|
||||
if (phase === 'intro') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">AI Act Entscheidungsbaum</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
Klassifizieren Sie Ihr KI-System anhand von 12 Fragen auf zwei Achsen:
|
||||
<strong> High-Risk</strong> (Anhang III) und <strong>GPAI</strong> (Art. 51–56).
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-5 h-5 text-orange-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126z" />
|
||||
</svg>
|
||||
<span className="font-medium text-orange-700">Achse 1: High-Risk</span>
|
||||
</div>
|
||||
<p className="text-sm text-orange-600">7 Fragen zu Anhang III Kategorien (Biometrie, kritische Infrastruktur, Bildung, Beschäftigung, etc.)</p>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
<span className="font-medium text-blue-700">Achse 2: GPAI</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-600">5 Fragen zu General-Purpose AI (Foundation Models, systemisches Risiko, Art. 51–56)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name des KI-Systems *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={systemName}
|
||||
onChange={e => setSystemName(e.target.value)}
|
||||
placeholder="z.B. Dokumenten-Analyse-KI, Chatbot-Service, Code-Assistent"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung (optional)</label>
|
||||
<textarea
|
||||
value={systemDescription}
|
||||
onChange={e => setSystemDescription(e.target.value)}
|
||||
placeholder="Kurze Beschreibung des KI-Systems und seines Einsatzzwecks..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button
|
||||
onClick={() => setPhase('questions')}
|
||||
disabled={!systemName.trim()}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
systemName.trim()
|
||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
Klassifizierung starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// RESULT PHASE
|
||||
// =========================================================================
|
||||
if (phase === 'result' && result) {
|
||||
const riskConfig = RISK_LEVEL_CONFIG[result.high_risk_result] || RISK_LEVEL_CONFIG.not_applicable
|
||||
const gpaiConfig = GPAI_CONFIG[result.gpai_result.gpai_category] || GPAI_CONFIG.none
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Klassifizierungsergebnis: {result.system_name}</h3>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||||
>
|
||||
Neue Klassifizierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Two-Axis Result Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div className={`p-5 rounded-xl border-2 ${riskConfig.border} ${riskConfig.bg}`}>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Achse 1: High-Risk (Anhang III)</div>
|
||||
<div className={`text-xl font-bold ${riskConfig.color}`}>{riskConfig.label}</div>
|
||||
</div>
|
||||
<div className={`p-5 rounded-xl border-2 ${gpaiConfig.border} ${gpaiConfig.bg}`}>
|
||||
<div className="text-sm font-medium text-gray-500 mb-1">Achse 2: GPAI (Art. 51–56)</div>
|
||||
<div className={`text-xl font-bold ${gpaiConfig.color}`}>{gpaiConfig.label}</div>
|
||||
{result.gpai_result.is_systemic_risk && (
|
||||
<div className="mt-1 text-xs text-purple-600 font-medium">Systemisches Risiko</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Applicable Articles */}
|
||||
{result.applicable_articles && result.applicable_articles.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Anwendbare Artikel</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{result.applicable_articles.map(art => (
|
||||
<span key={art} className="px-3 py-1 text-xs bg-indigo-50 text-indigo-700 rounded-full border border-indigo-200">
|
||||
{art}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Obligations */}
|
||||
{result.combined_obligations && result.combined_obligations.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">
|
||||
Pflichten ({result.combined_obligations.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.combined_obligations.map((obl, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-gray-700">{obl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GPAI-specific obligations */}
|
||||
{result.gpai_result.is_gpai && result.gpai_result.obligations.length > 0 && (
|
||||
<div className="bg-blue-50 rounded-xl border border-blue-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-blue-900 mb-3">
|
||||
GPAI-spezifische Pflichten ({result.gpai_result.obligations.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{result.gpai_result.obligations.map((obl, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<span className="text-blue-800">{obl}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer Summary */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="text-sm font-semibold text-gray-900 mb-3">Ihre Antworten</h4>
|
||||
<div className="space-y-2">
|
||||
{definition?.questions.map(q => {
|
||||
const answer = result.answers[q.id]
|
||||
if (!answer) return null
|
||||
return (
|
||||
<div key={q.id} className="flex items-center gap-3 text-sm py-1.5 border-b border-gray-100 last:border-0">
|
||||
<span className="text-xs font-mono text-gray-400 w-8">{q.id}</span>
|
||||
<span className="flex-1 text-gray-600">{q.question}</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
answer.value ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{answer.value ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// QUESTIONS PHASE
|
||||
// =========================================================================
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Progress */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{systemName} — Frage {currentIdx + 1} von {totalVisible}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full font-medium ${
|
||||
currentQuestion?.axis === 'high_risk'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-blue-100 text-blue-700'
|
||||
}`}>
|
||||
{currentQuestion?.axis === 'high_risk' ? 'High-Risk' : 'GPAI'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Dual progress bar */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] text-orange-600 mb-1 font-medium">
|
||||
Achse 1: High-Risk ({highRiskQuestions.filter(q => answers[q.id] !== undefined).length}/{highRiskQuestions.length})
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-orange-500 rounded-full transition-all"
|
||||
style={{ width: `${highRiskQuestions.length ? (highRiskQuestions.filter(q => answers[q.id] !== undefined).length / highRiskQuestions.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-[10px] text-blue-600 mb-1 font-medium">
|
||||
Achse 2: GPAI ({gpaiQuestions.filter(q => answers[q.id] !== undefined).length}/{gpaiQuestions.length})
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${gpaiQuestions.length ? (gpaiQuestions.filter(q => answers[q.id] !== undefined).length / gpaiQuestions.length) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="text-red-500 hover:text-red-700">×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Question */}
|
||||
{currentQuestion && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className="px-2 py-1 text-xs font-mono bg-gray-100 text-gray-500 rounded">{currentQuestion.id}</span>
|
||||
<span className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded">{currentQuestion.article_ref}</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">{currentQuestion.question}</h3>
|
||||
<p className="text-sm text-gray-500 mb-6">{currentQuestion.description}</p>
|
||||
|
||||
{/* Answer buttons */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => handleAnswer(true)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
|
||||
answers[currentQuestion.id]?.value === true
|
||||
? 'border-green-500 bg-green-50 text-green-700'
|
||||
: 'border-gray-200 hover:border-green-300 hover:bg-green-50/50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAnswer(false)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center font-medium ${
|
||||
answers[currentQuestion.id]?.value === false
|
||||
? 'border-gray-500 bg-gray-50 text-gray-700'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50/50 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Nein
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={currentIdx === 0 ? () => setPhase('intro') : handleBack}
|
||||
className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{visibleQuestions.map((q, i) => (
|
||||
<button
|
||||
key={q.id}
|
||||
onClick={() => setCurrentIdx(i)}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-colors ${
|
||||
i === currentIdx
|
||||
? q.axis === 'high_risk' ? 'bg-orange-500' : 'bg-blue-500'
|
||||
: answers[q.id] !== undefined
|
||||
? 'bg-green-400'
|
||||
: 'bg-gray-200'
|
||||
}`}
|
||||
title={`${q.id}: ${q.question}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{allAnswered ? (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={saving}
|
||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||
saving
|
||||
? 'bg-purple-300 text-white cursor-wait'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" 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>
|
||||
Auswertung...
|
||||
</span>
|
||||
) : (
|
||||
'Auswerten'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCurrentIdx(prev => Math.min(prev + 1, totalVisible - 1))}
|
||||
disabled={currentIdx >= totalVisible - 1}
|
||||
className="px-4 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors disabled:opacity-30"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
interface EnrichmentHint {
|
||||
field: string
|
||||
label: string
|
||||
impact: string
|
||||
regulation: string
|
||||
priority: string
|
||||
}
|
||||
|
||||
const PRIORITY_STYLES = {
|
||||
high: { icon: '⚠️', border: 'border-amber-300', bg: 'bg-amber-50' },
|
||||
medium: { icon: 'ℹ️', border: 'border-blue-200', bg: 'bg-blue-50' },
|
||||
low: { icon: '💡', border: 'border-gray-200', bg: 'bg-gray-50' },
|
||||
}
|
||||
|
||||
export function EnrichmentHints({ hints }: { hints: EnrichmentHint[] }) {
|
||||
if (!hints || hints.length === 0) return null
|
||||
|
||||
const highPriority = hints.filter(h => h.priority === 'high')
|
||||
const otherPriority = hints.filter(h => h.priority !== 'high')
|
||||
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">📋</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-amber-900">
|
||||
Bewertung verbessern — {hints.length} fehlende Firmendaten
|
||||
</h3>
|
||||
<p className="text-xs text-amber-700 mt-1 mb-3">
|
||||
Ergaenzen Sie diese Daten im Unternehmensprofil fuer eine vollstaendige regulatorische Bewertung.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{highPriority.map((h, i) => {
|
||||
const style = PRIORITY_STYLES[h.priority as keyof typeof PRIORITY_STYLES] || PRIORITY_STYLES.medium
|
||||
return (
|
||||
<div key={i} className={`flex items-start gap-2 ${style.bg} border ${style.border} rounded-lg px-3 py-2`}>
|
||||
<span className="text-sm">{style.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-800">{h.label}</span>
|
||||
<span className="text-xs text-gray-500 ml-2 px-1.5 py-0.5 bg-white rounded">{h.regulation}</span>
|
||||
<p className="text-xs text-gray-600 mt-0.5">{h.impact}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{otherPriority.map((h, i) => (
|
||||
<div key={`other-${i}`} className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<span>ℹ️</span>
|
||||
<span>{h.label}</span>
|
||||
<span className="text-xs text-gray-400">({h.regulation})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/sdk/company-profile"
|
||||
className="inline-flex items-center gap-1 mt-3 text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
Unternehmensprofil ergaenzen →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
|
||||
interface DimensionDelta {
|
||||
dimension: string
|
||||
from: string
|
||||
to: string
|
||||
impact: string
|
||||
}
|
||||
|
||||
const DIMENSION_LABELS: Record<string, string> = {
|
||||
automation_level: 'Automatisierungsgrad',
|
||||
decision_binding: 'Entscheidungsbindung',
|
||||
decision_impact: 'Entscheidungswirkung',
|
||||
domain: 'Branche',
|
||||
data_type: 'Datensensitivitaet',
|
||||
human_in_loop: 'Menschliche Kontrolle',
|
||||
explainability: 'Erklaerbarkeit',
|
||||
risk_classification: 'Risikoklasse',
|
||||
legal_basis: 'Rechtsgrundlage',
|
||||
transparency_required: 'Transparenzpflicht',
|
||||
logging_required: 'Protokollierung',
|
||||
model_type: 'Modelltyp',
|
||||
deployment_scope: 'Einsatzbereich',
|
||||
}
|
||||
|
||||
export function ConfigComparison({ deltas }: { deltas: DimensionDelta[] }) {
|
||||
if (deltas.length === 0) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-green-700 text-sm">
|
||||
Keine Aenderungen noetig — Ihre Konfiguration ist bereits konform.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">Empfohlene Aenderungen ({deltas.length})</h4>
|
||||
<div className="space-y-1">
|
||||
{deltas.map((d, i) => (
|
||||
<div key={i} className="flex items-center gap-2 bg-blue-50 border border-blue-200 rounded px-3 py-2 text-sm">
|
||||
<span className="font-medium text-gray-800 min-w-[160px]">
|
||||
{DIMENSION_LABELS[d.dimension] || d.dimension}
|
||||
</span>
|
||||
<span className="text-red-600 font-mono line-through">{d.from}</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-green-700 font-mono font-bold">{d.to}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user